diff --git a/eslint.config.mjs b/eslint.config.mjs index 92f49f59acc..c1ac2344dd9 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -5,7 +5,6 @@ import reactHooks from "eslint-plugin-react-hooks"; import jest from "eslint-plugin-jest"; import monorepo from "@jdb8/eslint-plugin-monorepo"; import * as rspRules from "eslint-plugin-rsp-rules"; -import { fixupPluginRules } from "@eslint/compat"; import globals from "globals"; import babelParser from "@babel/eslint-parser"; import typescriptEslint from "@typescript-eslint/eslint-plugin"; @@ -67,7 +66,7 @@ export default [{ react, rulesdir, "jsx-a11y": jsxA11Y, - "react-hooks": fixupPluginRules(reactHooks), + "react-hooks": reactHooks, jest, monorepo, "rsp-rules": rspRules, @@ -225,8 +224,28 @@ export default [{ "react/jsx-boolean-value": ERROR, "react/jsx-first-prop-new-line": [ERROR, "multiline"], "react/self-closing-comp": ERROR, + + // Core hooks rules "react-hooks/rules-of-hooks": ERROR, // https://github.com/facebook/react/blob/main/packages/eslint-plugin-react-hooks/CHANGELOG.md "react-hooks/exhaustive-deps": WARN, + + // React Compiler rules + 'react-hooks/config': ERROR, + 'react-hooks/error-boundaries': ERROR, + 'react-hooks/component-hook-factories': ERROR, + 'react-hooks/gating': ERROR, + // 'react-hooks/globals': ERROR, + // 'react-hooks/immutability': ERROR, + // 'react-hooks/preserve-manual-memoization': ERROR, + // 'react-hooks/purity': ERROR, + // 'react-hooks/refs': ERROR, + // 'react-hooks/set-state-in-effect': ERROR, + 'react-hooks/set-state-in-render': ERROR, + // 'react-hooks/static-components': ERROR, + 'react-hooks/unsupported-syntax': WARN, + 'react-hooks/use-memo': ERROR, + 'react-hooks/incompatible-library': WARN, + "rsp-rules/no-react-key": [ERROR], "rsp-rules/sort-imports": [ERROR], "rulesdir/imports": [ERROR], @@ -332,7 +351,7 @@ export default [{ react, rulesdir, "jsx-a11y": jsxA11Y, - "react-hooks": fixupPluginRules(reactHooks), + "react-hooks": reactHooks, jest, "@typescript-eslint": typescriptEslint, monorepo, diff --git a/package.json b/package.json index 6fde5bad643..cd51b2ecd1c 100644 --- a/package.json +++ b/package.json @@ -151,7 +151,7 @@ "eslint-plugin-jsdoc": "^50.4.1", "eslint-plugin-jsx-a11y": "^6.10.0", "eslint-plugin-react": "^7.37.1", - "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-react-hooks": "^7.0.0", "eslint-plugin-rulesdir": "^0.2.2", "fast-check": "^2.19.0", "fast-glob": "^3.1.0", diff --git a/packages/@react-aria/actiongroup/src/useActionGroupItem.ts b/packages/@react-aria/actiongroup/src/useActionGroupItem.ts index 9f33010a1eb..5cb90c01764 100644 --- a/packages/@react-aria/actiongroup/src/useActionGroupItem.ts +++ b/packages/@react-aria/actiongroup/src/useActionGroupItem.ts @@ -54,7 +54,7 @@ export function useActionGroupItem(props: AriaActionGroupItemProps, state: Li return () => { onRemovedWithFocus(); }; - }, [onRemovedWithFocus]); + }, []); return { buttonProps: mergeProps(buttonProps, { diff --git a/packages/@react-aria/autocomplete/src/useAutocomplete.ts b/packages/@react-aria/autocomplete/src/useAutocomplete.ts index 2110caebeef..b9c1e6420ee 100644 --- a/packages/@react-aria/autocomplete/src/useAutocomplete.ts +++ b/packages/@react-aria/autocomplete/src/useAutocomplete.ts @@ -13,7 +13,7 @@ import {AriaLabelingProps, BaseEvent, DOMProps, FocusableElement, FocusEvents, KeyboardEvents, Node, RefObject, ValueBase} from '@react-types/shared'; import {AriaTextFieldProps} from '@react-aria/textfield'; import {AutocompleteProps, AutocompleteState} from '@react-stately/autocomplete'; -import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, getActiveElement, getOwnerDocument, isAndroid, isCtrlKeyPressed, isIOS, mergeProps, mergeRefs, useEffectEvent, useEvent, useId, useLabels, useObjectRef} from '@react-aria/utils'; +import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, getActiveElement, getOwnerDocument, isAndroid, isCtrlKeyPressed, isIOS, mergeProps, mergeRefs, useEffectEvent, useEvent, useId, useLabels, useLayoutEffect, useObjectRef} from '@react-aria/utils'; import {dispatchVirtualBlur, dispatchVirtualFocus, getVirtuallyFocusedElement, moveVirtualFocus} from '@react-aria/focus'; import {getInteractionModality} from '@react-aria/interactions'; // @ts-ignore @@ -92,7 +92,6 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut let timeout = useRef | undefined>(undefined); let delayNextActiveDescendant = useRef(false); let queuedActiveDescendant = useRef(null); - let lastCollectionNode = useRef(null); // For mobile screen readers, we don't want virtual focus, instead opting to disable FocusScope's restoreFocus and manually // moving focus back to the subtriggers @@ -106,7 +105,7 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut return () => clearTimeout(timeout.current); }, []); - let updateActiveDescendant = useEffectEvent((e: Event) => { + let updateActiveDescendantEvent = useEffectEvent((e: Event) => { // Ensure input is focused if the user clicks on the collection directly. if (!e.isTrusted && shouldUseVirtualFocus && inputRef.current && getActiveElement(getOwnerDocument(inputRef.current)) !== inputRef.current) { inputRef.current.focus(); @@ -140,32 +139,36 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut delayNextActiveDescendant.current = false; }); - let callbackRef = useCallback((collectionNode) => { - if (collectionNode != null) { - // When typing forward, we want to delay the setting of active descendant to not interrupt the native screen reader announcement - // of the letter you just typed. If we recieve another focus event then we clear the queued update - // We track lastCollectionNode to do proper cleanup since callbackRefs just pass null when unmounting. This also handles - // React 19's extra call of the callback ref in strict mode - lastCollectionNode.current?.removeEventListener('focusin', updateActiveDescendant); - lastCollectionNode.current = collectionNode; - collectionNode.addEventListener('focusin', updateActiveDescendant); + let [collectionNode, setCollectionNode] = useState(null); + let callbackRef = useCallback((node) => { + setCollectionNode(node); + if (node != null) { // If useSelectableCollection isn't passed shouldUseVirtualFocus even when useAutocomplete provides it // that means the collection doesn't support it (e.g. Table). If that is the case, we need to disable it here regardless // of what the user's provided so that the input doesn't recieve the onKeyDown and autocomplete props. - if (collectionNode.getAttribute('tabindex') != null) { + if (node.getAttribute('tabindex') != null) { setShouldUseVirtualFocus(false); } setHasCollection(true); } else { - lastCollectionNode.current?.removeEventListener('focusin', updateActiveDescendant); setHasCollection(false); } - }, [updateActiveDescendant]); + }, []); + useLayoutEffect(() => { + if (collectionNode != null) { + // When typing forward, we want to delay the setting of active descendant to not interrupt the native screen reader announcement + // of the letter you just typed. If we recieve another focus event then we clear the queued update + collectionNode.addEventListener('focusin', updateActiveDescendantEvent); + } + return () => { + collectionNode?.removeEventListener('focusin', updateActiveDescendantEvent); + }; + }, [collectionNode]); // Make sure to memo so that React doesn't keep registering a new event listeners on every rerender of the wrapped collection let mergedCollectionRef = useObjectRef(useMemo(() => mergeRefs(collectionRef, callbackRef), [collectionRef, callbackRef])); - let focusFirstItem = useEffectEvent(() => { + let focusFirstItem = useCallback(() => { delayNextActiveDescendant.current = true; collectionRef.current?.dispatchEvent( new CustomEvent(FOCUS_EVENT, { @@ -176,9 +179,9 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut } }) ); - }); + }, [collectionRef]); - let clearVirtualFocus = useEffectEvent((clearFocusKey?: boolean) => { + let clearVirtualFocus = useCallback((clearFocusKey?: boolean) => { moveVirtualFocus(getActiveElement()); queuedActiveDescendant.current = null; state.setFocusedNodeId(null); @@ -192,7 +195,7 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut clearTimeout(timeout.current); delayNextActiveDescendant.current = false; collectionRef.current?.dispatchEvent(clearFocusEvent); - }); + }, [collectionRef, state]); let lastInputType = useRef(''); useEvent(inputRef, 'input', e => { @@ -346,7 +349,7 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut return () => { document.removeEventListener('keyup', onKeyUpCapture, true); }; - }, [onKeyUpCapture]); + }, []); let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/autocomplete'); let collectionProps = useLabels({ diff --git a/packages/@react-aria/dnd/src/useClipboard.ts b/packages/@react-aria/dnd/src/useClipboard.ts index 53f23ee148b..fb883fa598e 100644 --- a/packages/@react-aria/dnd/src/useClipboard.ts +++ b/packages/@react-aria/dnd/src/useClipboard.ts @@ -143,7 +143,7 @@ export function useClipboard(options: ClipboardProps): ClipboardResult { addGlobalEventListener('beforepaste', onBeforePaste), addGlobalEventListener('paste', onPaste) ); - }, [isDisabled, onBeforeCopy, onCopy, onBeforeCut, onCut, onBeforePaste, onPaste]); + }, [isDisabled]); return { clipboardProps: focusProps diff --git a/packages/@react-aria/dnd/src/useDrop.ts b/packages/@react-aria/dnd/src/useDrop.ts index 09985b2d7a5..31b2204546b 100644 --- a/packages/@react-aria/dnd/src/useDrop.ts +++ b/packages/@react-aria/dnd/src/useDrop.ts @@ -339,7 +339,7 @@ export function useDrop(options: DropOptions): DropResult { onDrop: onKeyboardDrop, onDropActivate }); - }, [isDisabled, ref, getDropOperationKeyboard, onDropEnter, onDropExit, onKeyboardDrop, onDropActivate]); + }, [isDisabled, ref]); let {dropProps} = useVirtualDrop(); if (isDisabled) { diff --git a/packages/@react-aria/form/src/useFormValidation.ts b/packages/@react-aria/form/src/useFormValidation.ts index 034814fd6f2..b4699bf8046 100644 --- a/packages/@react-aria/form/src/useFormValidation.ts +++ b/packages/@react-aria/form/src/useFormValidation.ts @@ -112,7 +112,7 @@ export function useFormValidation(props: FormValidationProps, state: FormV form.reset = reset; } }; - }, [ref, onInvalid, onChange, onReset, validationBehavior]); + }, [ref, validationBehavior]); } function getValidity(input: ValidatableElement) { diff --git a/packages/@react-aria/grid/src/useGridSelectionAnnouncement.ts b/packages/@react-aria/grid/src/useGridSelectionAnnouncement.ts index c3d84564c32..5d129aedba9 100644 --- a/packages/@react-aria/grid/src/useGridSelectionAnnouncement.ts +++ b/packages/@react-aria/grid/src/useGridSelectionAnnouncement.ts @@ -15,9 +15,9 @@ import {Collection, Key, Node, Selection} from '@react-types/shared'; // @ts-ignore import intlMessages from '../intl/*.json'; import {SelectionManager} from '@react-stately/selection'; -import {useEffectEvent, useUpdateEffect} from '@react-aria/utils'; +import {useCallback, useRef} from 'react'; import {useLocalizedStringFormatter} from '@react-aria/i18n'; -import {useRef} from 'react'; +import {useUpdateEffect} from '@react-aria/utils'; export interface GridSelectionAnnouncementProps { /** @@ -46,7 +46,7 @@ export function useGridSelectionAnnouncement(props: GridSelectionAnnouncement // We do this using an ARIA live region. let selection = state.selectionManager.rawSelection; let lastSelection = useRef(selection); - let announceSelectionChange = useEffectEvent(() => { + let announceSelectionChange = useCallback(() => { if (!state.selectionManager.isFocused || selection === lastSelection.current) { lastSelection.current = selection; @@ -101,8 +101,18 @@ export function useGridSelectionAnnouncement(props: GridSelectionAnnouncement } lastSelection.current = selection; - }); - + }, [ + selection, + state.selectionManager.selectedKeys, + state.selectionManager.isFocused, + state.selectionManager.selectionBehavior, + state.selectionManager.selectionMode, + state.collection, + getRowText, + stringFormatter + ]); + + // useUpdateEffect will handle using useEffectEvent, no need to stabilize anything on this end useUpdateEffect(() => { if (state.selectionManager.isFocused) { announceSelectionChange(); diff --git a/packages/@react-aria/interactions/src/useInteractOutside.ts b/packages/@react-aria/interactions/src/useInteractOutside.ts index 94c1e65a46c..9f413630ca3 100644 --- a/packages/@react-aria/interactions/src/useInteractOutside.ts +++ b/packages/@react-aria/interactions/src/useInteractOutside.ts @@ -111,7 +111,7 @@ export function useInteractOutside(props: InteractOutsideProps): void { documentObject.removeEventListener('touchend', onTouchEnd, true); }; } - }, [ref, isDisabled, onPointerDown, triggerInteractOutside]); + }, [ref, isDisabled]); } function isValidEvent(event, ref) { diff --git a/packages/@react-aria/interactions/src/useMove.ts b/packages/@react-aria/interactions/src/useMove.ts index f7f32acfdbb..c8158b4f9c3 100644 --- a/packages/@react-aria/interactions/src/useMove.ts +++ b/packages/@react-aria/interactions/src/useMove.ts @@ -12,8 +12,8 @@ import {disableTextSelection, restoreTextSelection} from './textSelection'; import {DOMAttributes, MoveEvents, PointerType} from '@react-types/shared'; -import React, {useMemo, useRef} from 'react'; -import {useEffectEvent, useGlobalListeners} from '@react-aria/utils'; +import React, {useCallback, useMemo, useRef, useState} from 'react'; +import {useEffectEvent, useGlobalListeners, useLayoutEffect} from '@react-aria/utils'; export interface MoveResult { /** Props to spread on the target element. */ @@ -43,7 +43,7 @@ export function useMove(props: MoveEvents): MoveResult { let {addGlobalListener, removeGlobalListener} = useGlobalListeners(); - let move = useEffectEvent((originalEvent: EventBase, pointerType: PointerType, deltaX: number, deltaY: number) => { + let move = useCallback((originalEvent: EventBase, pointerType: PointerType, deltaX: number, deltaY: number) => { if (deltaX === 0 && deltaY === 0) { return; } @@ -69,9 +69,10 @@ export function useMove(props: MoveEvents): MoveResult { ctrlKey: originalEvent.ctrlKey, altKey: originalEvent.altKey }); - }); + }, [onMoveStart, onMove, state]); + let moveEvent = useEffectEvent(move); - let end = useEffectEvent((originalEvent: EventBase, pointerType: PointerType) => { + let end = useCallback((originalEvent: EventBase, pointerType: PointerType) => { restoreTextSelection(); if (state.current.didMove) { onMoveEnd?.({ @@ -83,57 +84,111 @@ export function useMove(props: MoveEvents): MoveResult { altKey: originalEvent.altKey }); } - }); + }, [onMoveEnd, state]); + let endEvent = useEffectEvent(end); - let moveProps = useMemo(() => { - let moveProps: DOMAttributes = {}; + let [pointerDown, setPointerDown] = useState<'pointer' | 'mouse' | 'touch' | null>(null); + useLayoutEffect(() => { + if (pointerDown === 'pointer') { + let onPointerMove = (e: PointerEvent) => { + if (e.pointerId === state.current.id) { + let pointerType = (e.pointerType || 'mouse') as PointerType; - let start = () => { - disableTextSelection(); - state.current.didMove = false; - }; + // Problems with PointerEvent#movementX/movementY: + // 1. it is always 0 on macOS Safari. + // 2. On Chrome Android, it's scaled by devicePixelRatio, but not on Chrome macOS + moveEvent(e, pointerType, e.pageX - (state.current.lastPosition?.pageX ?? 0), e.pageY - (state.current.lastPosition?.pageY ?? 0)); + state.current.lastPosition = {pageX: e.pageX, pageY: e.pageY}; + } + }; - if (typeof PointerEvent === 'undefined' && process.env.NODE_ENV === 'test') { + let onPointerUp = (e: PointerEvent) => { + if (e.pointerId === state.current.id) { + let pointerType = (e.pointerType || 'mouse') as PointerType; + endEvent(e, pointerType); + state.current.id = null; + removeGlobalListener(window, 'pointermove', onPointerMove, false); + removeGlobalListener(window, 'pointerup', onPointerUp, false); + removeGlobalListener(window, 'pointercancel', onPointerUp, false); + setPointerDown(null); + } + }; + addGlobalListener(window, 'pointermove', onPointerMove, false); + addGlobalListener(window, 'pointerup', onPointerUp, false); + addGlobalListener(window, 'pointercancel', onPointerUp, false); + return () => { + removeGlobalListener(window, 'pointermove', onPointerMove, false); + removeGlobalListener(window, 'pointerup', onPointerUp, false); + removeGlobalListener(window, 'pointercancel', onPointerUp, false); + }; + } else if (pointerDown === 'mouse' && process.env.NODE_ENV === 'test') { let onMouseMove = (e: MouseEvent) => { if (e.button === 0) { - move(e, 'mouse', e.pageX - (state.current.lastPosition?.pageX ?? 0), e.pageY - (state.current.lastPosition?.pageY ?? 0)); + moveEvent(e, 'mouse', e.pageX - (state.current.lastPosition?.pageX ?? 0), e.pageY - (state.current.lastPosition?.pageY ?? 0)); state.current.lastPosition = {pageX: e.pageX, pageY: e.pageY}; } }; let onMouseUp = (e: MouseEvent) => { if (e.button === 0) { - end(e, 'mouse'); + endEvent(e, 'mouse'); removeGlobalListener(window, 'mousemove', onMouseMove, false); removeGlobalListener(window, 'mouseup', onMouseUp, false); + setPointerDown(null); } }; - moveProps.onMouseDown = (e: React.MouseEvent) => { - if (e.button === 0) { - start(); - e.stopPropagation(); - e.preventDefault(); - state.current.lastPosition = {pageX: e.pageX, pageY: e.pageY}; - addGlobalListener(window, 'mousemove', onMouseMove, false); - addGlobalListener(window, 'mouseup', onMouseUp, false); - } + addGlobalListener(window, 'mousemove', onMouseMove, false); + addGlobalListener(window, 'mouseup', onMouseUp, false); + return () => { + removeGlobalListener(window, 'mousemove', onMouseMove, false); + removeGlobalListener(window, 'mouseup', onMouseUp, false); }; - + } else if (pointerDown === 'touch' && process.env.NODE_ENV === 'test') { let onTouchMove = (e: TouchEvent) => { let touch = [...e.changedTouches].findIndex(({identifier}) => identifier === state.current.id); if (touch >= 0) { let {pageX, pageY} = e.changedTouches[touch]; - move(e, 'touch', pageX - (state.current.lastPosition?.pageX ?? 0), pageY - (state.current.lastPosition?.pageY ?? 0)); + moveEvent(e, 'touch', pageX - (state.current.lastPosition?.pageX ?? 0), pageY - (state.current.lastPosition?.pageY ?? 0)); state.current.lastPosition = {pageX, pageY}; } }; let onTouchEnd = (e: TouchEvent) => { let touch = [...e.changedTouches].findIndex(({identifier}) => identifier === state.current.id); if (touch >= 0) { - end(e, 'touch'); + endEvent(e, 'touch'); state.current.id = null; removeGlobalListener(window, 'touchmove', onTouchMove); removeGlobalListener(window, 'touchend', onTouchEnd); removeGlobalListener(window, 'touchcancel', onTouchEnd); + setPointerDown(null); + } + }; + addGlobalListener(window, 'touchmove', onTouchMove, false); + addGlobalListener(window, 'touchend', onTouchEnd, false); + addGlobalListener(window, 'touchcancel', onTouchEnd, false); + return () => { + removeGlobalListener(window, 'touchmove', onTouchMove, false); + removeGlobalListener(window, 'touchend', onTouchEnd, false); + removeGlobalListener(window, 'touchcancel', onTouchEnd, false); + }; + } + }, [pointerDown, addGlobalListener, removeGlobalListener]); + + let moveProps = useMemo(() => { + let moveProps: DOMAttributes = {}; + + let start = () => { + disableTextSelection(); + state.current.didMove = false; + }; + + if (typeof PointerEvent === 'undefined' && process.env.NODE_ENV === 'test') { + moveProps.onMouseDown = (e: React.MouseEvent) => { + if (e.button === 0) { + start(); + e.stopPropagation(); + e.preventDefault(); + state.current.lastPosition = {pageX: e.pageX, pageY: e.pageY}; + setPointerDown('mouse'); } }; moveProps.onTouchStart = (e: React.TouchEvent) => { @@ -147,34 +202,9 @@ export function useMove(props: MoveEvents): MoveResult { e.preventDefault(); state.current.lastPosition = {pageX, pageY}; state.current.id = identifier; - addGlobalListener(window, 'touchmove', onTouchMove, false); - addGlobalListener(window, 'touchend', onTouchEnd, false); - addGlobalListener(window, 'touchcancel', onTouchEnd, false); + setPointerDown('touch'); }; } else { - let onPointerMove = (e: PointerEvent) => { - if (e.pointerId === state.current.id) { - let pointerType = (e.pointerType || 'mouse') as PointerType; - - // Problems with PointerEvent#movementX/movementY: - // 1. it is always 0 on macOS Safari. - // 2. On Chrome Android, it's scaled by devicePixelRatio, but not on Chrome macOS - move(e, pointerType, e.pageX - (state.current.lastPosition?.pageX ?? 0), e.pageY - (state.current.lastPosition?.pageY ?? 0)); - state.current.lastPosition = {pageX: e.pageX, pageY: e.pageY}; - } - }; - - let onPointerUp = (e: PointerEvent) => { - if (e.pointerId === state.current.id) { - let pointerType = (e.pointerType || 'mouse') as PointerType; - end(e, pointerType); - state.current.id = null; - removeGlobalListener(window, 'pointermove', onPointerMove, false); - removeGlobalListener(window, 'pointerup', onPointerUp, false); - removeGlobalListener(window, 'pointercancel', onPointerUp, false); - } - }; - moveProps.onPointerDown = (e: React.PointerEvent) => { if (e.button === 0 && state.current.id == null) { start(); @@ -182,9 +212,7 @@ export function useMove(props: MoveEvents): MoveResult { e.preventDefault(); state.current.lastPosition = {pageX: e.pageX, pageY: e.pageY}; state.current.id = e.pointerId; - addGlobalListener(window, 'pointermove', onPointerMove, false); - addGlobalListener(window, 'pointerup', onPointerUp, false); - addGlobalListener(window, 'pointercancel', onPointerUp, false); + setPointerDown('pointer'); } }; } @@ -225,7 +253,7 @@ export function useMove(props: MoveEvents): MoveResult { }; return moveProps; - }, [state, addGlobalListener, removeGlobalListener, move, end]); + }, [state, move, end]); return {moveProps}; } diff --git a/packages/@react-aria/interactions/src/usePress.ts b/packages/@react-aria/interactions/src/usePress.ts index 42576b36095..6dc4fd7f757 100644 --- a/packages/@react-aria/interactions/src/usePress.ts +++ b/packages/@react-aria/interactions/src/usePress.ts @@ -29,6 +29,7 @@ import { openLink, useEffectEvent, useGlobalListeners, + useLayoutEffect, useSyncRef } from '@react-aria/utils'; import {createSyntheticEvent, preventFocus, setEventTarget} from './utils'; @@ -36,7 +37,7 @@ import {disableTextSelection, restoreTextSelection} from './textSelection'; import {DOMAttributes, FocusableElement, PressEvent as IPressEvent, PointerType, PressEvents, RefObject} from '@react-types/shared'; import {flushSync} from 'react-dom'; import {PressResponderContext} from './context'; -import {MouseEvent as RMouseEvent, TouchEvent as RTouchEvent, useContext, useEffect, useMemo, useRef, useState} from 'react'; +import {MouseEvent as RMouseEvent, TouchEvent as RTouchEvent, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; export interface PressProps extends PressEvents { /** Whether the target is in a controlled press state (e.g. an overlay it triggers is open). */ @@ -195,9 +196,9 @@ export function usePress(props: PressHookProps): PressResult { disposables: [] }); - let {addGlobalListener, removeAllGlobalListeners} = useGlobalListeners(); + let {addGlobalListener, removeAllGlobalListeners, removeGlobalListener} = useGlobalListeners(); - let triggerPressStart = useEffectEvent((originalEvent: EventBase, pointerType: PointerType) => { + let triggerPressStart = useCallback((originalEvent: EventBase, pointerType: PointerType) => { let state = ref.current; if (isDisabled || state.didFirePressStart) { return false; @@ -219,9 +220,9 @@ export function usePress(props: PressHookProps): PressResult { state.didFirePressStart = true; setPressed(true); return shouldStopPropagation; - }); + }, [isDisabled, onPressStart, onPressChange]); - let triggerPressEnd = useEffectEvent((originalEvent: EventBase, pointerType: PointerType, wasPressed = true) => { + let triggerPressEnd = useCallback((originalEvent: EventBase, pointerType: PointerType, wasPressed = true) => { let state = ref.current; if (!state.didFirePressStart) { return false; @@ -251,9 +252,10 @@ export function usePress(props: PressHookProps): PressResult { state.isTriggeringEvent = false; return shouldStopPropagation; - }); + }, [isDisabled, onPressEnd, onPressChange, onPress]); + let triggerPressEndEvent = useEffectEvent(triggerPressEnd); - let triggerPressUp = useEffectEvent((originalEvent: EventBase, pointerType: PointerType) => { + let triggerPressUp = useCallback((originalEvent: EventBase, pointerType: PointerType) => { let state = ref.current; if (isDisabled) { return false; @@ -268,15 +270,17 @@ export function usePress(props: PressHookProps): PressResult { } return true; - }); + }, [isDisabled, onPressUp]); + let triggerPressUpEvent = useEffectEvent(triggerPressUp); - let cancel = useEffectEvent((e: EventBase) => { + let cancel = useCallback((e: EventBase) => { let state = ref.current; if (state.isPressed && state.target) { if (state.didFirePressStart && state.pointerType != null) { triggerPressEnd(createEvent(state.target, e), state.pointerType, false); } state.isPressed = false; + setIsPointerPressed(null); state.isOverTarget = false; state.activePointerId = null; state.pointerType = null; @@ -289,23 +293,24 @@ export function usePress(props: PressHookProps): PressResult { } state.disposables = []; } - }); + }, [allowTextSelectionOnPress, removeAllGlobalListeners, triggerPressEnd]); + let cancelEvent = useEffectEvent(cancel); - let cancelOnPointerExit = useEffectEvent((e: EventBase) => { + let cancelOnPointerExit = useCallback((e: EventBase) => { if (shouldCancelOnPointerExit) { cancel(e); } - }); + }, [shouldCancelOnPointerExit, cancel]); - let triggerClick = useEffectEvent((e: RMouseEvent) => { + let triggerClick = useCallback((e: RMouseEvent) => { if (isDisabled) { return; } onClick?.(e); - }); + }, [isDisabled, onClick]); - let triggerSyntheticClick = useEffectEvent((e: KeyboardEvent | TouchEvent, target: FocusableElement) => { + let triggerSyntheticClick = useCallback((e: KeyboardEvent | TouchEvent, target: FocusableElement) => { if (isDisabled) { return; } @@ -320,7 +325,164 @@ export function usePress(props: PressHookProps): PressResult { setEventTarget(event, target); onClick(createSyntheticEvent(event)); } - }); + }, [isDisabled, onClick]); + let triggerSyntheticClickEvent = useEffectEvent(triggerSyntheticClick); + + let [isElemKeyPressed, setIsElemKeyPressed] = useState(false); + useLayoutEffect(() => { + let state = ref.current; + if (isElemKeyPressed) { + let onKeyUp = (e: KeyboardEvent) => { + if (state.isPressed && state.target && isValidKeyboardEvent(e, state.target)) { + if (shouldPreventDefaultKeyboard(getEventTarget(e), e.key)) { + e.preventDefault(); + } + + let target = getEventTarget(e); + let wasPressed = nodeContains(state.target, getEventTarget(e)); + triggerPressEndEvent(createEvent(state.target, e), 'keyboard', wasPressed); + if (wasPressed) { + triggerSyntheticClickEvent(e, state.target); + } + removeAllGlobalListeners(); + + // If a link was triggered with a key other than Enter, open the URL ourselves. + // This means the link has a role override, and the default browser behavior + // only applies when using the Enter key. + if (e.key !== 'Enter' && isHTMLAnchorLink(state.target) && nodeContains(state.target, target) && !e[LINK_CLICKED]) { + // Store a hidden property on the event so we only trigger link click once, + // even if there are multiple usePress instances attached to the element. + e[LINK_CLICKED] = true; + openLink(state.target, e, false); + } + + state.isPressed = false; + setIsElemKeyPressed(false); + state.metaKeyEvents?.delete(e.key); + } else if (e.key === 'Meta' && state.metaKeyEvents?.size) { + // If we recorded keydown events that occurred while the Meta key was pressed, + // and those haven't received keyup events already, fire keyup events ourselves. + // See comment above for more info about the macOS bug causing this. + let events = state.metaKeyEvents; + state.metaKeyEvents = undefined; + for (let event of events.values()) { + state.target?.dispatchEvent(new KeyboardEvent('keyup', event)); + } + } + }; + // Focus may move before the key up event, so register the event on the document + // instead of the same element where the key down event occurred. Make it capturing so that it will trigger + // before stopPropagation from useKeyboard on a child element may happen and thus we can still call triggerPress for the parent element. + let originalTarget = state.target; + let pressUp = (e) => { + if (originalTarget && isValidKeyboardEvent(e, originalTarget) && !e.repeat && nodeContains(originalTarget, getEventTarget(e)) && state.target) { + triggerPressUpEvent(createEvent(state.target, e), 'keyboard'); + } + }; + let listener = chain(pressUp, onKeyUp); + addGlobalListener(getOwnerDocument(state.target), 'keyup', listener, true); + return () => { + removeGlobalListener(getOwnerDocument(state.target), 'keyup', listener, true); + }; + } + }, [isElemKeyPressed, addGlobalListener, removeAllGlobalListeners, removeGlobalListener]); + + let [isPointerPressed, setIsPointerPressed] = useState<'pointer' | 'mouse' | 'touch' | null>(null); + useLayoutEffect(() => { + let state = ref.current; + if (isPointerPressed === 'pointer') { + let onPointerUp = (e: PointerEvent) => { + if (e.pointerId === state.activePointerId && state.isPressed && e.button === 0 && state.target) { + if (nodeContains(state.target, getEventTarget(e)) && state.pointerType != null) { + // Wait for onClick to fire onPress. This avoids browser issues when the DOM + // is mutated between onPointerUp and onClick, and is more compatible with third party libraries. + // https://github.com/adobe/react-spectrum/issues/1513 + // https://issues.chromium.org/issues/40732224 + // However, iOS and Android do not focus or fire onClick after a long press. + // We work around this by triggering a click ourselves after a timeout. + // This timeout is canceled during the click event in case the real one fires first. + // The timeout must be at least 32ms, because Safari on iOS delays the click event on + // non-form elements without certain ARIA roles (for hover emulation). + // https://github.com/WebKit/WebKit/blob/dccfae42bb29bd4bdef052e469f604a9387241c0/Source/WebKit/WebProcess/WebPage/ios/WebPageIOS.mm#L875-L892 + let clicked = false; + let timeout = setTimeout(() => { + if (state.isPressed && state.target instanceof HTMLElement) { + if (clicked) { + cancelEvent(e); + } else { + focusWithoutScrolling(state.target); + state.target.click(); + } + } + }, 80); + // Use a capturing listener to track if a click occurred. + // If stopPropagation is called it may never reach our handler. + addGlobalListener(e.currentTarget as Document, 'click', () => clicked = true, true); + state.disposables.push(() => clearTimeout(timeout)); + } else { + cancelEvent(e); + } + + // Ignore subsequent onPointerLeave event before onClick on touch devices. + state.isOverTarget = false; + } + }; + + let onPointerCancel = (e: PointerEvent) => { + cancelEvent(e); + }; + + addGlobalListener(getOwnerDocument(state.target), 'pointerup', onPointerUp, false); + addGlobalListener(getOwnerDocument(state.target), 'pointercancel', onPointerCancel, false); + return () => { + removeGlobalListener(getOwnerDocument(state.target), 'pointerup', onPointerUp, false); + removeGlobalListener(getOwnerDocument(state.target), 'pointercancel', onPointerCancel, false); + }; + } else if (isPointerPressed === 'mouse' && process.env.NODE_ENV === 'test') { + let onMouseUp = (e: MouseEvent) => { + // Only handle left clicks + if (e.button !== 0) { + return; + } + + if (state.ignoreEmulatedMouseEvents) { + state.ignoreEmulatedMouseEvents = false; + return; + } + + if (state.target && state.target.contains(e.target as Element) && state.pointerType != null) { + // Wait for onClick to fire onPress. This avoids browser issues when the DOM + // is mutated between onMouseUp and onClick, and is more compatible with third party libraries. + } else { + cancelEvent(e); + } + + state.isOverTarget = false; + }; + + addGlobalListener(getOwnerDocument(state.target), 'mouseup', onMouseUp, false); + return () => { + removeGlobalListener(getOwnerDocument(state.target), 'mouseup', onMouseUp, false); + }; + } else if (isPointerPressed === 'touch' && process.env.NODE_ENV === 'test') { + let onScroll = (e: Event) => { + if (state.isPressed && nodeContains(getEventTarget(e), state.target)) { + cancelEvent({ + currentTarget: state.target, + shiftKey: false, + ctrlKey: false, + metaKey: false, + altKey: false + }); + } + }; + + addGlobalListener(getOwnerWindow(state.target), 'scroll', onScroll, true); + return () => { + removeGlobalListener(getOwnerWindow(state.target), 'scroll', onScroll, true); + }; + } + }, [isPointerPressed, addGlobalListener, removeGlobalListener]); let pressProps = useMemo(() => { let state = ref.current; @@ -338,20 +500,9 @@ export function usePress(props: PressHookProps): PressResult { if (!state.isPressed && !e.repeat) { state.target = e.currentTarget; state.isPressed = true; + setIsElemKeyPressed(true); state.pointerType = 'keyboard'; shouldStopPropagation = triggerPressStart(e, 'keyboard'); - - // Focus may move before the key up event, so register the event on the document - // instead of the same element where the key down event occurred. Make it capturing so that it will trigger - // before stopPropagation from useKeyboard on a child element may happen and thus we can still call triggerPress for the parent element. - let originalTarget = e.currentTarget; - let pressUp = (e) => { - if (isValidKeyboardEvent(e, originalTarget) && !e.repeat && nodeContains(originalTarget, getEventTarget(e)) && state.target) { - triggerPressUp(createEvent(state.target, e), 'keyboard'); - } - }; - - addGlobalListener(getOwnerDocument(e.currentTarget), 'keyup', chain(pressUp, onKeyUp), true); } if (shouldStopPropagation) { @@ -409,44 +560,6 @@ export function usePress(props: PressHookProps): PressResult { } }; - let onKeyUp = (e: KeyboardEvent) => { - if (state.isPressed && state.target && isValidKeyboardEvent(e, state.target)) { - if (shouldPreventDefaultKeyboard(getEventTarget(e), e.key)) { - e.preventDefault(); - } - - let target = getEventTarget(e); - let wasPressed = nodeContains(state.target, getEventTarget(e)); - triggerPressEnd(createEvent(state.target, e), 'keyboard', wasPressed); - if (wasPressed) { - triggerSyntheticClick(e, state.target); - } - removeAllGlobalListeners(); - - // If a link was triggered with a key other than Enter, open the URL ourselves. - // This means the link has a role override, and the default browser behavior - // only applies when using the Enter key. - if (e.key !== 'Enter' && isHTMLAnchorLink(state.target) && nodeContains(state.target, target) && !e[LINK_CLICKED]) { - // Store a hidden property on the event so we only trigger link click once, - // even if there are multiple usePress instances attached to the element. - e[LINK_CLICKED] = true; - openLink(state.target, e, false); - } - - state.isPressed = false; - state.metaKeyEvents?.delete(e.key); - } else if (e.key === 'Meta' && state.metaKeyEvents?.size) { - // If we recorded keydown events that occurred while the Meta key was pressed, - // and those haven't received keyup events already, fire keyup events ourselves. - // See comment above for more info about the macOS bug causing this. - let events = state.metaKeyEvents; - state.metaKeyEvents = undefined; - for (let event of events.values()) { - state.target?.dispatchEvent(new KeyboardEvent('keyup', event)); - } - } - }; - if (typeof PointerEvent !== 'undefined') { pressProps.onPointerDown = (e) => { // Only handle left clicks, and ignore events that bubbled through portals. @@ -468,6 +581,7 @@ export function usePress(props: PressHookProps): PressResult { let shouldStopPropagation = true; if (!state.isPressed) { state.isPressed = true; + setIsPointerPressed('pointer'); state.isOverTarget = true; state.activePointerId = e.pointerId; state.target = e.currentTarget as FocusableElement; @@ -484,9 +598,6 @@ export function usePress(props: PressHookProps): PressResult { if ('releasePointerCapture' in target) { target.releasePointerCapture(e.pointerId); } - - addGlobalListener(getOwnerDocument(e.currentTarget), 'pointerup', onPointerUp, false); - addGlobalListener(getOwnerDocument(e.currentTarget), 'pointercancel', onPointerCancel, false); } if (shouldStopPropagation) { @@ -538,46 +649,6 @@ export function usePress(props: PressHookProps): PressResult { } }; - let onPointerUp = (e: PointerEvent) => { - if (e.pointerId === state.activePointerId && state.isPressed && e.button === 0 && state.target) { - if (nodeContains(state.target, getEventTarget(e)) && state.pointerType != null) { - // Wait for onClick to fire onPress. This avoids browser issues when the DOM - // is mutated between onPointerUp and onClick, and is more compatible with third party libraries. - // https://github.com/adobe/react-spectrum/issues/1513 - // https://issues.chromium.org/issues/40732224 - // However, iOS and Android do not focus or fire onClick after a long press. - // We work around this by triggering a click ourselves after a timeout. - // This timeout is canceled during the click event in case the real one fires first. - // The timeout must be at least 32ms, because Safari on iOS delays the click event on - // non-form elements without certain ARIA roles (for hover emulation). - // https://github.com/WebKit/WebKit/blob/dccfae42bb29bd4bdef052e469f604a9387241c0/Source/WebKit/WebProcess/WebPage/ios/WebPageIOS.mm#L875-L892 - let clicked = false; - let timeout = setTimeout(() => { - if (state.isPressed && state.target instanceof HTMLElement) { - if (clicked) { - cancel(e); - } else { - focusWithoutScrolling(state.target); - state.target.click(); - } - } - }, 80); - // Use a capturing listener to track if a click occurred. - // If stopPropagation is called it may never reach our handler. - addGlobalListener(e.currentTarget as Document, 'click', () => clicked = true, true); - state.disposables.push(() => clearTimeout(timeout)); - } else { - cancel(e); - } - - // Ignore subsequent onPointerLeave event before onClick on touch devices. - state.isOverTarget = false; - } - }; - - let onPointerCancel = (e: PointerEvent) => { - cancel(e); - }; pressProps.onDragStart = (e) => { if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) { @@ -603,6 +674,7 @@ export function usePress(props: PressHookProps): PressResult { } state.isPressed = true; + setIsPointerPressed('mouse'); state.isOverTarget = true; state.target = e.currentTarget; state.pointerType = isVirtualClick(e.nativeEvent) ? 'virtual' : 'mouse'; @@ -619,8 +691,6 @@ export function usePress(props: PressHookProps): PressResult { state.disposables.push(dispose); } } - - addGlobalListener(getOwnerDocument(e.currentTarget), 'mouseup', onMouseUp, false); }; pressProps.onMouseEnter = (e) => { @@ -666,27 +736,6 @@ export function usePress(props: PressHookProps): PressResult { } }; - let onMouseUp = (e: MouseEvent) => { - // Only handle left clicks - if (e.button !== 0) { - return; - } - - if (state.ignoreEmulatedMouseEvents) { - state.ignoreEmulatedMouseEvents = false; - return; - } - - if (state.target && state.target.contains(e.target as Element) && state.pointerType != null) { - // Wait for onClick to fire onPress. This avoids browser issues when the DOM - // is mutated between onMouseUp and onClick, and is more compatible with third party libraries. - } else { - cancel(e); - } - - state.isOverTarget = false; - }; - pressProps.onTouchStart = (e) => { if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) { return; @@ -700,6 +749,7 @@ export function usePress(props: PressHookProps): PressResult { state.ignoreEmulatedMouseEvents = true; state.isOverTarget = true; state.isPressed = true; + setIsPointerPressed('touch'); state.target = e.currentTarget; state.pointerType = 'touch'; @@ -711,8 +761,6 @@ export function usePress(props: PressHookProps): PressResult { if (shouldStopPropagation) { e.stopPropagation(); } - - addGlobalListener(getOwnerWindow(e.currentTarget), 'scroll', onScroll, true); }; pressProps.onTouchMove = (e) => { @@ -768,6 +816,7 @@ export function usePress(props: PressHookProps): PressResult { } state.isPressed = false; + setIsPointerPressed(null); state.activePointerId = null; state.isOverTarget = false; state.ignoreEmulatedMouseEvents = true; @@ -788,18 +837,6 @@ export function usePress(props: PressHookProps): PressResult { } }; - let onScroll = (e: Event) => { - if (state.isPressed && nodeContains(getEventTarget(e), state.target)) { - cancel({ - currentTarget: state.target, - shiftKey: false, - ctrlKey: false, - metaKey: false, - altKey: false - }); - } - }; - pressProps.onDragStart = (e) => { if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) { return; @@ -811,7 +848,6 @@ export function usePress(props: PressHookProps): PressResult { return pressProps; }, [ - addGlobalListener, isDisabled, preventFocusOnPress, removeAllGlobalListeners, diff --git a/packages/@react-aria/interactions/src/utils.ts b/packages/@react-aria/interactions/src/utils.ts index f31da638bc9..46321a1d4a8 100644 --- a/packages/@react-aria/interactions/src/utils.ts +++ b/packages/@react-aria/interactions/src/utils.ts @@ -11,7 +11,7 @@ */ import {FocusableElement} from '@react-types/shared'; -import {focusWithoutScrolling, getOwnerWindow, isFocusable, useEffectEvent, useLayoutEffect} from '@react-aria/utils'; +import {focusWithoutScrolling, getOwnerWindow, isFocusable, useLayoutEffect} from '@react-aria/utils'; import {FocusEvent as ReactFocusEvent, SyntheticEvent, useCallback, useRef} from 'react'; // Turn a native event into a React synthetic event. @@ -48,10 +48,6 @@ export function useSyntheticBlurEvent(onBlur: }; }, []); - let dispatchBlur = useEffectEvent((e: ReactFocusEvent) => { - onBlur?.(e); - }); - // This function is called during a React onFocus event. return useCallback((e: ReactFocusEvent) => { // React does not fire onBlur when an element is disabled. https://github.com/facebook/react/issues/9142 @@ -73,7 +69,7 @@ export function useSyntheticBlurEvent(onBlur: if (target.disabled) { // For backward compatibility, dispatch a (fake) React synthetic event. let event = createSyntheticEvent>(e); - dispatchBlur(event); + onBlur?.(event); } // We no longer need the MutationObserver once the target is blurred. @@ -96,7 +92,7 @@ export function useSyntheticBlurEvent(onBlur: stateRef.current.observer.observe(target, {attributes: true, attributeFilter: ['disabled']}); } - }, [dispatchBlur]); + }, [onBlur]); } export let ignoreFocusEvent = false; diff --git a/packages/@react-aria/menu/src/useSafelyMouseToSubmenu.ts b/packages/@react-aria/menu/src/useSafelyMouseToSubmenu.ts index 1a43971bbe4..f36eb66261e 100644 --- a/packages/@react-aria/menu/src/useSafelyMouseToSubmenu.ts +++ b/packages/@react-aria/menu/src/useSafelyMouseToSubmenu.ts @@ -174,5 +174,5 @@ export function useSafelyMouseToSubmenu(options: SafelyMouseToSubmenuOptions): v movementsTowardsSubmenuCount.current = ALLOWED_INVALID_MOVEMENTS; }; - }, [isDisabled, isOpen, menuRef, modality, setPreventPointerEvents, onPointerDown, submenuRef]); + }, [isDisabled, isOpen, menuRef, modality, setPreventPointerEvents, submenuRef]); } diff --git a/packages/@react-aria/menu/src/useSubmenuTrigger.ts b/packages/@react-aria/menu/src/useSubmenuTrigger.ts index 590ce43a213..eed89771089 100644 --- a/packages/@react-aria/menu/src/useSubmenuTrigger.ts +++ b/packages/@react-aria/menu/src/useSubmenuTrigger.ts @@ -14,7 +14,7 @@ import {AriaMenuItemProps} from './useMenuItem'; import {AriaMenuOptions} from './useMenu'; import type {AriaPopoverProps, OverlayProps} from '@react-aria/overlays'; import {FocusableElement, FocusStrategy, KeyboardEvent, Node, PressEvent, RefObject} from '@react-types/shared'; -import {focusWithoutScrolling, useEffectEvent, useEvent, useId, useLayoutEffect} from '@react-aria/utils'; +import {focusWithoutScrolling, useEvent, useId, useLayoutEffect} from '@react-aria/utils'; import type {SubmenuTriggerState} from '@react-stately/menu'; import {useCallback, useRef} from 'react'; import {useLocale} from '@react-aria/i18n'; @@ -81,15 +81,15 @@ export function useSubmenuTrigger(props: AriaSubmenuTriggerProps, state: Subm } }, [openTimeout]); - let onSubmenuOpen = useEffectEvent((focusStrategy?: FocusStrategy) => { + let onSubmenuOpen = useCallback((focusStrategy?: FocusStrategy) => { cancelOpenTimeout(); state.open(focusStrategy); - }); + }, [state, cancelOpenTimeout]); - let onSubmenuClose = useEffectEvent(() => { + let onSubmenuClose = useCallback(() => { cancelOpenTimeout(); state.close(); - }); + }, [state, cancelOpenTimeout]); useLayoutEffect(() => { return () => { diff --git a/packages/@react-aria/selection/src/useSelectableCollection.ts b/packages/@react-aria/selection/src/useSelectableCollection.ts index 4259dc51132..f44cb0a123d 100644 --- a/packages/@react-aria/selection/src/useSelectableCollection.ts +++ b/packages/@react-aria/selection/src/useSelectableCollection.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, focusWithoutScrolling, getActiveElement, isCtrlKeyPressed, isTabbable, mergeProps, scrollIntoView, scrollIntoViewport, useEffectEvent, useEvent, useRouter, useUpdateLayoutEffect} from '@react-aria/utils'; +import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, focusWithoutScrolling, getActiveElement, isCtrlKeyPressed, isTabbable, mergeProps, scrollIntoView, scrollIntoViewport, useEvent, useRouter, useUpdateLayoutEffect} from '@react-aria/utils'; import {dispatchVirtualFocus, getFocusableTreeWalker, moveVirtualFocus} from '@react-aria/focus'; import {DOMAttributes, FocusableElement, FocusStrategy, Key, KeyboardDelegate, RefObject} from '@react-types/shared'; import {flushSync} from 'react-dom'; @@ -416,49 +416,42 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions } }); - let updateActiveDescendant = useEffectEvent(() => { - let keyToFocus = delegate.getFirstKey?.() ?? null; - - // If no focusable items exist in the list, make sure to clear any activedescendant that may still exist and move focus back to - // the original active element (e.g. the autocomplete input) - if (keyToFocus == null) { - let previousActiveElement = getActiveElement(); - moveVirtualFocus(ref.current); - dispatchVirtualFocus(previousActiveElement!, null); - - // If there wasn't a focusable key but the collection had items, then that means we aren't in an intermediate load state and all keys are disabled. - // Reset shouldVirtualFocusFirst so that we don't erronously autofocus an item when the collection is filtered again. - if (manager.collection.size > 0) { + // update active descendant + useUpdateLayoutEffect(() => { + if (shouldVirtualFocusFirst.current) { + let keyToFocus = delegate.getFirstKey?.() ?? null; + + // If no focusable items exist in the list, make sure to clear any activedescendant that may still exist and move focus back to + // the original active element (e.g. the autocomplete input) + if (keyToFocus == null) { + let previousActiveElement = getActiveElement(); + moveVirtualFocus(ref.current); + dispatchVirtualFocus(previousActiveElement!, null); + + // If there wasn't a focusable key but the collection had items, then that means we aren't in an intermediate load state and all keys are disabled. + // Reset shouldVirtualFocusFirst so that we don't erronously autofocus an item when the collection is filtered again. + if (manager.collection.size > 0) { + shouldVirtualFocusFirst.current = false; + } + } else { + manager.setFocusedKey(keyToFocus); + // Only set shouldVirtualFocusFirst to false if we've successfully set the first key as the focused key + // If there wasn't a key to focus, we might be in a temporary loading state so we'll want to still focus the first key + // after the collection updates after load shouldVirtualFocusFirst.current = false; } - } else { - manager.setFocusedKey(keyToFocus); - // Only set shouldVirtualFocusFirst to false if we've successfully set the first key as the focused key - // If there wasn't a key to focus, we might be in a temporary loading state so we'll want to still focus the first key - // after the collection updates after load - shouldVirtualFocusFirst.current = false; } - }); + }, [manager.collection]); + // reset focus first flag useUpdateLayoutEffect(() => { - if (shouldVirtualFocusFirst.current) { - updateActiveDescendant(); - } - - }, [manager.collection, updateActiveDescendant]); - - let resetFocusFirstFlag = useEffectEvent(() => { // If user causes the focused key to change in any other way, clear shouldVirtualFocusFirst so we don't // accidentally move focus from under them. Skip this if the collection was empty because we might be in a load // state and will still want to focus the first item after load if (manager.collection.size > 0) { shouldVirtualFocusFirst.current = false; } - }); - - useUpdateLayoutEffect(() => { - resetFocusFirstFlag(); - }, [manager.focusedKey, resetFocusFirstFlag]); + }, [manager.focusedKey]); useEvent(ref, CLEAR_FOCUS_EVENT, !shouldUseVirtualFocus ? undefined : (e: any) => { e.stopPropagation(); diff --git a/packages/@react-aria/spinbutton/src/useSpinButton.ts b/packages/@react-aria/spinbutton/src/useSpinButton.ts index dada685076b..de772fa5106 100644 --- a/packages/@react-aria/spinbutton/src/useSpinButton.ts +++ b/packages/@react-aria/spinbutton/src/useSpinButton.ts @@ -15,7 +15,7 @@ import {AriaButtonProps} from '@react-types/button'; import {DOMAttributes, InputBase, RangeInputBase, Validation, ValueBase} from '@react-types/shared'; // @ts-ignore import intlMessages from '../intl/*.json'; -import {useCallback, useEffect, useRef} from 'react'; +import {useCallback, useEffect, useRef, useState} from 'react'; import {useEffectEvent, useGlobalListeners} from '@react-aria/utils'; import {useLocalizedStringFormatter} from '@react-aria/i18n'; @@ -64,7 +64,6 @@ export function useSpinButton( isSpinning.current = false; }; - useEffect(() => { return () => clearAsync(); }, []); @@ -199,6 +198,24 @@ export function useSpinButton( // an increment or decrement. let isUp = useRef(false); + let [isIncrementPressed, setIsIncrementPressed] = useState<'touch' | 'mouse' | null>(null); + useEffect(() => { + if (isIncrementPressed === 'touch') { + onIncrementPressStart(60); + } else if (isIncrementPressed) { + onIncrementPressStart(400); + } + }, [isIncrementPressed]); + + let [isDecrementPressed, setIsDecrementPressed] = useState<'touch' | 'mouse' | null>(null); + useEffect(() => { + if (isDecrementPressed === 'touch') { + onDecrementPressStart(60); + } else if (isDecrementPressed) { + onDecrementPressStart(400); + } + }, [isDecrementPressed]); + return { spinButtonProps: { role: 'spinbutton', @@ -216,19 +233,19 @@ export function useSpinButton( incrementButtonProps: { onPressStart: (e) => { if (e.pointerType !== 'touch') { - onIncrementPressStart(400); + setIsIncrementPressed('mouse'); } else { if (_async.current) { clearAsync(); } - // For touch users, don't trigger an increment on press start, we'll wait for the press end to trigger it if - // the control isn't spinning. - _async.current = window.setTimeout(() => { - onIncrementPressStart(60); - }, 600); addGlobalListener(window, 'touchmove', onTouchMove, {capture: true}); isUp.current = false; + // For touch users, don't trigger a decrement on press start, we'll wait for the press end to trigger it if + // the control isn't spinning. + _async.current = window.setTimeout(() => { + setIsIncrementPressed('touch'); + }, 600); } addGlobalListener(window, 'contextmenu', cancelContextMenu); }, @@ -239,6 +256,7 @@ export function useSpinButton( prevTouchPosition.current = null; clearAsync(); removeAllGlobalListeners(); + setIsIncrementPressed(null); }, onPressEnd: (e) => { if (e.pointerType === 'touch') { @@ -247,6 +265,7 @@ export function useSpinButton( } } isUp.current = false; + setIsIncrementPressed(null); }, onFocus, onBlur @@ -254,19 +273,19 @@ export function useSpinButton( decrementButtonProps: { onPressStart: (e) => { if (e.pointerType !== 'touch') { - onDecrementPressStart(400); + setIsDecrementPressed('mouse'); } else { if (_async.current) { clearAsync(); } + + addGlobalListener(window, 'touchmove', onTouchMove, {capture: true}); + isUp.current = false; // For touch users, don't trigger a decrement on press start, we'll wait for the press end to trigger it if // the control isn't spinning. _async.current = window.setTimeout(() => { - onDecrementPressStart(60); + setIsDecrementPressed('touch'); }, 600); - - addGlobalListener(window, 'touchmove', onTouchMove, {capture: true}); - isUp.current = false; } }, onPressUp: (e) => { @@ -276,6 +295,7 @@ export function useSpinButton( prevTouchPosition.current = null; clearAsync(); removeAllGlobalListeners(); + setIsDecrementPressed(null); }, onPressEnd: (e) => { if (e.pointerType === 'touch') { @@ -284,6 +304,7 @@ export function useSpinButton( } } isUp.current = false; + setIsDecrementPressed(null); }, onFocus, onBlur diff --git a/packages/@react-aria/table/src/useTableColumnResize.ts b/packages/@react-aria/table/src/useTableColumnResize.ts index 0a8c956ab13..7d02274c946 100644 --- a/packages/@react-aria/table/src/useTableColumnResize.ts +++ b/packages/@react-aria/table/src/useTableColumnResize.ts @@ -70,25 +70,8 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st let editModeEnabled = state.tableState.isKeyboardNavigationDisabled; let {direction} = useLocale(); - let {keyboardProps} = useKeyboard({ - onKeyDown: (e) => { - if (editModeEnabled) { - if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ' || e.key === 'Tab') { - e.preventDefault(); - endResize(item); - } - } else { - // Continue propagation on keydown events so they still bubbles to useSelectableCollection and are handled there - e.continuePropagation(); - if (e.key === 'Enter') { - startResize(item); - } - } - } - }); - - let startResize = useEffectEvent((item) => { + let startResize = useCallback((item) => { if (!isResizingRef.current) { lastSize.current = state.updateResizedColumns(item.key, state.getColumnWidth(item.key)); state.startResize(item.key); @@ -96,15 +79,15 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st onResizeStart?.(lastSize.current); } isResizingRef.current = true; - }); + }, [state, onResizeStart]); - let resize = useEffectEvent((item, newWidth) => { + let resize = useCallback((item, newWidth) => { let sizes = state.updateResizedColumns(item.key, newWidth); onResize?.(sizes); lastSize.current = sizes; - }); + }, [state, onResize]); - let endResize = useEffectEvent((item) => { + let endResize = useCallback((item) => { if (isResizingRef.current) { if (lastSize.current == null) { lastSize.current = state.updateResizedColumns(item.key, state.getColumnWidth(item.key)); @@ -121,6 +104,24 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st } } lastSize.current = null; + }, [state, triggerRef, onResizeEnd]); + + let {keyboardProps} = useKeyboard({ + onKeyDown: (e) => { + if (editModeEnabled) { + if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ' || e.key === 'Tab') { + e.preventDefault(); + endResize(item); + } + } else { + // Continue propagation on keydown events so they still bubbles to useSelectableCollection and are handled there + e.continuePropagation(); + + if (e.key === 'Enter') { + startResize(item); + } + } + } }); const columnResizeWidthRef = useRef(0); @@ -194,10 +195,11 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st let resizingColumn = state.resizingColumn; let prevResizingColumn = useRef(null); + let startResizeEvent = useEffectEvent(startResize); useEffect(() => { if (prevResizingColumn.current !== resizingColumn && resizingColumn != null && resizingColumn === item.key) { wasFocusedOnResizeStart.current = document.activeElement === ref.current; - startResize(item); + startResizeEvent(item); // Delay focusing input until Android Chrome's delayed click after touchend happens: https://bugs.chromium.org/p/chromium/issues/detail?id=1150073 let timeout = setTimeout(() => focusInput(), 0); // VoiceOver on iOS has problems focusing the input from a menu. @@ -208,7 +210,7 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st }; } prevResizingColumn.current = resizingColumn; - }, [resizingColumn, item, focusInput, ref, startResize]); + }, [resizingColumn, item, focusInput, ref]); let onChange = (e: ChangeEvent) => { let currentWidth = state.getColumnWidth(item.key); diff --git a/packages/@react-aria/textfield/src/useFormattedTextField.ts b/packages/@react-aria/textfield/src/useFormattedTextField.ts index 878deb9c841..e0d866fd016 100644 --- a/packages/@react-aria/textfield/src/useFormattedTextField.ts +++ b/packages/@react-aria/textfield/src/useFormattedTextField.ts @@ -104,7 +104,7 @@ export function useFormattedTextField(props: AriaTextFieldProps, state: Formatte return () => { input.removeEventListener('beforeinput', onBeforeInputFallback, false); }; - }, [inputRef, onBeforeInputFallback]); + }, [inputRef]); let onBeforeInput = !supportsNativeBeforeInputEvent() ? e => { diff --git a/packages/@react-aria/toast/src/useToastRegion.ts b/packages/@react-aria/toast/src/useToastRegion.ts index 04d6a8dceed..1c6c3dbaf84 100644 --- a/packages/@react-aria/toast/src/useToastRegion.ts +++ b/packages/@react-aria/toast/src/useToastRegion.ts @@ -11,12 +11,12 @@ */ import {AriaLabelingProps, DOMAttributes, FocusableElement, RefObject} from '@react-types/shared'; -import {focusWithoutScrolling, mergeProps, useEffectEvent, useLayoutEffect} from '@react-aria/utils'; +import {focusWithoutScrolling, mergeProps, useLayoutEffect} from '@react-aria/utils'; import {getInteractionModality, useFocusWithin, useHover} from '@react-aria/interactions'; // @ts-ignore import intlMessages from '../intl/*.json'; import {ToastState} from '@react-stately/toast'; -import {useEffect, useRef} from 'react'; +import {useCallback, useEffect, useRef} from 'react'; import {useLandmark} from '@react-aria/landmark'; import {useLocalizedStringFormatter} from '@react-aria/i18n'; @@ -46,13 +46,13 @@ export function useToastRegion(props: AriaToastRegionProps, state: ToastState let isHovered = useRef(false); let isFocused = useRef(false); - let updateTimers = useEffectEvent(() => { + let updateTimers = useCallback(() => { if (isHovered.current || isFocused.current) { state.pauseAll(); } else { state.resumeAll(); } - }); + }, [state]); let {hoverProps} = useHover({ onHoverStart: () => { @@ -133,7 +133,7 @@ export function useToastRegion(props: AriaToastRegionProps, state: ToastState } prevVisibleToasts.current = state.visibleToasts; - }, [state.visibleToasts, ref, updateTimers]); + }, [state.visibleToasts, ref]); let lastFocused = useRef(null); let {focusWithinProps} = useFocusWithin({ diff --git a/packages/@react-aria/utils/src/useEvent.ts b/packages/@react-aria/utils/src/useEvent.ts index a56710b906c..1dd35499847 100644 --- a/packages/@react-aria/utils/src/useEvent.ts +++ b/packages/@react-aria/utils/src/useEvent.ts @@ -33,5 +33,5 @@ export function useEvent( return () => { element.removeEventListener(event, handleEvent as EventListener, options); }; - }, [ref, event, options, isDisabled, handleEvent]); + }, [ref, event, options, isDisabled]); } diff --git a/packages/@react-aria/utils/src/useFormReset.ts b/packages/@react-aria/utils/src/useFormReset.ts index 749c5e38788..c37eb67cc50 100644 --- a/packages/@react-aria/utils/src/useFormReset.ts +++ b/packages/@react-aria/utils/src/useFormReset.ts @@ -32,5 +32,5 @@ export function useFormReset( return () => { form?.removeEventListener('reset', handleReset); }; - }, [ref, handleReset]); + }, [ref]); } diff --git a/packages/@react-aria/utils/src/useLoadMoreSentinel.ts b/packages/@react-aria/utils/src/useLoadMoreSentinel.ts index 2d02f2ca101..457124261b4 100644 --- a/packages/@react-aria/utils/src/useLoadMoreSentinel.ts +++ b/packages/@react-aria/utils/src/useLoadMoreSentinel.ts @@ -59,5 +59,5 @@ export function useLoadMoreSentinel(props: LoadMoreSentinelProps, ref: RefObject sentinelObserver.current.disconnect(); } }; - }, [collection, triggerLoadMore, ref, scrollOffset]); + }, [collection, ref, scrollOffset]); } diff --git a/packages/@react-aria/utils/src/useResizeObserver.ts b/packages/@react-aria/utils/src/useResizeObserver.ts index 32d9e8a4e4b..ab0a1d5ae3c 100644 --- a/packages/@react-aria/utils/src/useResizeObserver.ts +++ b/packages/@react-aria/utils/src/useResizeObserver.ts @@ -1,6 +1,7 @@ import {RefObject} from '@react-types/shared'; import {useEffect} from 'react'; +import {useEffectEvent} from './useEffectEvent'; function hasResizeObserver() { return typeof window.ResizeObserver !== 'undefined'; @@ -13,7 +14,10 @@ type useResizeObserverOptionsType = { } export function useResizeObserver(options: useResizeObserverOptionsType): void { + // Only call onResize from inside the effect, otherwise we'll void our assumption that + // useEffectEvents are safe to pass in. const {ref, box, onResize} = options; + let onResizeEvent = useEffectEvent(onResize); useEffect(() => { let element = ref?.current; @@ -22,9 +26,9 @@ export function useResizeObserver(options: useResizeObserverO } if (!hasResizeObserver()) { - window.addEventListener('resize', onResize, false); + window.addEventListener('resize', onResizeEvent, false); return () => { - window.removeEventListener('resize', onResize, false); + window.removeEventListener('resize', onResizeEvent, false); }; } else { @@ -33,7 +37,7 @@ export function useResizeObserver(options: useResizeObserverO return; } - onResize(); + onResizeEvent(); }); resizeObserverInstance.observe(element, {box}); @@ -44,5 +48,5 @@ export function useResizeObserver(options: useResizeObserverO }; } - }, [onResize, ref, box]); + }, [ref, box]); } diff --git a/packages/@react-aria/utils/src/useUpdateEffect.ts b/packages/@react-aria/utils/src/useUpdateEffect.ts index d486e61baa6..e1aff96e964 100644 --- a/packages/@react-aria/utils/src/useUpdateEffect.ts +++ b/packages/@react-aria/utils/src/useUpdateEffect.ts @@ -11,11 +11,13 @@ */ import {EffectCallback, useEffect, useRef} from 'react'; +import {useEffectEvent} from './useEffectEvent'; // Like useEffect, but only called for updates after the initial render. -export function useUpdateEffect(effect: EffectCallback, dependencies: any[]): void { +export function useUpdateEffect(cb: EffectCallback, dependencies: any[]): void { const isInitialMount = useRef(true); const lastDeps = useRef(null); + let cbEvent = useEffectEvent(cb); useEffect(() => { isInitialMount.current = true; @@ -29,9 +31,9 @@ export function useUpdateEffect(effect: EffectCallback, dependencies: any[]): vo if (isInitialMount.current) { isInitialMount.current = false; } else if (!prevDeps || dependencies.some((dep, i) => !Object.is(dep, prevDeps[i]))) { - effect(); + cbEvent(); } lastDeps.current = dependencies; - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, dependencies); } diff --git a/packages/@react-aria/utils/src/useValueEffect.ts b/packages/@react-aria/utils/src/useValueEffect.ts index a8c90397aea..f22ee14fb05 100644 --- a/packages/@react-aria/utils/src/useValueEffect.ts +++ b/packages/@react-aria/utils/src/useValueEffect.ts @@ -10,8 +10,8 @@ * governing permissions and limitations under the License. */ -import {Dispatch, MutableRefObject, useRef, useState} from 'react'; -import {useEffectEvent, useLayoutEffect} from './'; +import {Dispatch, RefObject, useCallback, useRef, useState} from 'react'; +import {useLayoutEffect} from './'; type SetValueAction = (prev: S) => Generator; @@ -21,11 +21,14 @@ type SetValueAction = (prev: S) => Generator; // written linearly. export function useValueEffect(defaultValue: S | (() => S)): [S, Dispatch>] { let [value, setValue] = useState(defaultValue); - let effect: MutableRefObject | null> = useRef | null>(null); + // Keep an up to date copy of value in a ref so we can access the current value in the generator. + // This allows us to maintain a stable queue function. + let currValue = useRef(value); + let effect: RefObject | null> = useRef | null>(null); // Store the function in a ref so we can always access the current version // which has the proper `value` in scope. - let nextRef = useEffectEvent(() => { + let nextRef = useRef(() => { if (!effect.current) { return; } @@ -41,24 +44,25 @@ export function useValueEffect(defaultValue: S | (() => S)): [S, Dispatch { + currValue.current = value; // If there is an effect currently running, continue to the next yield. if (effect.current) { - nextRef(); + nextRef.current(); } }); - let queue = useEffectEvent(fn => { - effect.current = fn(value); - nextRef(); - }); + let queue = useCallback(fn => { + effect.current = fn(currValue.current); + nextRef.current(); + }, [nextRef]); return [value, queue]; } diff --git a/packages/@react-aria/virtualizer/src/ScrollView.tsx b/packages/@react-aria/virtualizer/src/ScrollView.tsx index 7c4988a5b3e..b57efc4f19e 100644 --- a/packages/@react-aria/virtualizer/src/ScrollView.tsx +++ b/packages/@react-aria/virtualizer/src/ScrollView.tsx @@ -157,7 +157,7 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject { + let updateSize = useCallback((flush: typeof flushSync) => { let dom = ref.current; if (!dom || isUpdatingSize.current) { return; @@ -197,11 +197,14 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject(null); let [update, setUpdate] = useState({}); + // We only contain a call to setState in here for testing environments. + // eslint-disable-next-line react-hooks/exhaustive-deps useLayoutEffect(() => { if (!isUpdatingSize.current && (lastContentSize.current == null || !contentSize.equals(lastContentSize.current))) { // React doesn't allow flushSync inside effects, so queue a microtask. @@ -218,7 +221,7 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject updateSize(flushSync)); + queueMicrotask(() => updateSizeEvent(flushSync)); } } @@ -227,7 +230,7 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject { - updateSize(fn => fn()); + updateSizeEvent(fn => fn()); }, [update]); let onResize = useCallback(() => { diff --git a/packages/@react-spectrum/s2/src/CardView.tsx b/packages/@react-spectrum/s2/src/CardView.tsx index e589a1cbd4a..2023a8c0d95 100644 --- a/packages/@react-spectrum/s2/src/CardView.tsx +++ b/packages/@react-spectrum/s2/src/CardView.tsx @@ -24,7 +24,7 @@ import { WaterfallLayout } from 'react-aria-components'; import {CardContext, InternalCardViewContext} from './Card'; -import {createContext, forwardRef, ReactElement, useMemo, useRef, useState} from 'react'; +import {createContext, forwardRef, ReactElement, useCallback, useMemo, useRef, useState} from 'react'; import {DOMRef, DOMRefValue, forwardRefType, GlobalDOMAttributes, Key, LoadingState} from '@react-types/shared'; import {focusRing, style} from '../style' with {type: 'macro'}; import {getAllowedOverrides, StylesPropWithHeight, UnsafeStyles} from './style-utils' with {type: 'macro'}; @@ -218,7 +218,7 @@ export const CardView = /*#__PURE__*/ (forwardRef as forwardRefType)(function Ca // This calculates the maximum t-shirt size where at least two columns fit in the available width. let [maxSizeIndex, setMaxSizeIndex] = useState(SIZES.length - 1); - let updateSize = useEffectEvent(() => { + let updateSize = useCallback(() => { let w = scrollRef.current?.clientWidth ?? 0; let i = SIZES.length - 1; while (i > 0) { @@ -229,7 +229,8 @@ export const CardView = /*#__PURE__*/ (forwardRef as forwardRefType)(function Ca i--; } setMaxSizeIndex(i); - }); + }, [scrollRef, density]); + let updateSizeEvent = useEffectEvent(updateSize); useResizeObserver({ ref: scrollRef, @@ -238,8 +239,8 @@ export const CardView = /*#__PURE__*/ (forwardRef as forwardRefType)(function Ca }); useLayoutEffect(() => { - updateSize(); - }, [updateSize]); + updateSizeEvent(); + }, []); // The actual rendered t-shirt size is the minimum between the size prop and the maximum possible size. let size = SIZES[Math.min(maxSizeIndex, SIZES.indexOf(sizeProp))]; diff --git a/packages/@react-spectrum/s2/src/CoachMark.tsx b/packages/@react-spectrum/s2/src/CoachMark.tsx index a4d6cc0a70d..41229c198d1 100644 --- a/packages/@react-spectrum/s2/src/CoachMark.tsx +++ b/packages/@react-spectrum/s2/src/CoachMark.tsx @@ -504,6 +504,7 @@ export const CoachMarkIndicator = /*#__PURE__*/ (forwardRef as forwardRefType)(f objRef.current.style.minWidth = childMinWidth; objRef.current.style.minHeight = childMinHeight; } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [children]); return ( diff --git a/packages/@react-spectrum/s2/src/Skeleton.tsx b/packages/@react-spectrum/s2/src/Skeleton.tsx index 1fb2bdb185b..b8ebd13cba4 100644 --- a/packages/@react-spectrum/s2/src/Skeleton.tsx +++ b/packages/@react-spectrum/s2/src/Skeleton.tsx @@ -41,7 +41,7 @@ export function useLoadingAnimation(isAnimating: boolean): (element: HTMLElement animationRef.current.cancel(); animationRef.current = null; } - }, [isAnimating]); + }, [isAnimating, reduceMotion]); } export type SkeletonElement = ReactElement<{ diff --git a/packages/@react-spectrum/s2/src/Tabs.tsx b/packages/@react-spectrum/s2/src/Tabs.tsx index 329a6284daa..e3af2c8fb9b 100644 --- a/packages/@react-spectrum/s2/src/Tabs.tsx +++ b/packages/@react-spectrum/s2/src/Tabs.tsx @@ -216,7 +216,7 @@ export function TabList(props: TabListProps): ReactNode | n if (showTabs) { return ; } - + return (
{listRef &&
@@ -628,7 +628,8 @@ let CollapsingTabs = ({collection, containerRef, ...props}: {collection: Collect let children = useMemo(() => [...collection], [collection]); let listRef = useRef(null); - let updateOverflow = useEffectEvent(() => { + + let updateOverflow = () => { if (orientation === 'vertical' || !listRef.current || !containerRef?.current) { return; } @@ -642,29 +643,30 @@ let CollapsingTabs = ({collection, containerRef, ...props}: {collection: Collect } else { setShowItems?.(lastTabRect.left >= containerRect.left); } - }); + }; + + let updateOverflowEffect = useEffectEvent(updateOverflow); useResizeObserver({ref: containerRef, onResize: updateOverflow}); useLayoutEffect(() => { if (collection.size > 0) { - queueMicrotask(updateOverflow); + queueMicrotask(updateOverflowEffect); } - }, [collection.size, updateOverflow]); + }, [collection.size]); // start with null so that the first render won't have a flicker let prevOrientation = useRef(null); useLayoutEffect(() => { if (collection.size > 0 && prevOrientation.current !== orientation) { - updateOverflow(); + updateOverflowEffect(); } prevOrientation.current = orientation; - }, [collection.size, updateOverflow, orientation]); + }, [collection.size, orientation]); useEffect(() => { // Recalculate visible tags when fonts are loaded. - document.fonts?.ready.then(() => updateOverflow()); - // eslint-disable-next-line react-hooks/exhaustive-deps + document.fonts?.ready.then(() => updateOverflowEffect()); }, []); let menuId = useId(); diff --git a/packages/@react-spectrum/s2/src/TagGroup.tsx b/packages/@react-spectrum/s2/src/TagGroup.tsx index ce68e04deda..8a67d056c8c 100644 --- a/packages/@react-spectrum/s2/src/TagGroup.tsx +++ b/packages/@react-spectrum/s2/src/TagGroup.tsx @@ -147,7 +147,7 @@ function TagGroupInner({ [collection, tagState.visibleTagCount, isCollapsed] ); - let updateVisibleTagCount = useEffectEvent(() => { + let updateVisibleTagCount = () => { if (maxRows == null) { setTagState({visibleTagCount: collection.size, showCollapseButton: false}); } @@ -217,20 +217,21 @@ function TagGroupInner({ setTagState(result); }); } - }); + }; + + let updateVisibleTagCountEffect = useEffectEvent(updateVisibleTagCount); useResizeObserver({ref: maxRows != null ? containerRef : undefined, onResize: updateVisibleTagCount}); useLayoutEffect(() => { if (collection.size > 0 && (maxRows != null && maxRows > 0)) { - queueMicrotask(updateVisibleTagCount); + queueMicrotask(updateVisibleTagCountEffect); } - }, [collection.size, updateVisibleTagCount, maxRows]); + }, [collection.size, maxRows]); useEffect(() => { // Recalculate visible tags when fonts are loaded. - document.fonts?.ready.then(() => updateVisibleTagCount()); - // eslint-disable-next-line react-hooks/exhaustive-deps + document.fonts?.ready.then(() => updateVisibleTagCountEffect()); }, []); let handlePressCollapse = () => { diff --git a/packages/@react-spectrum/table/stories/Table.stories.tsx b/packages/@react-spectrum/table/stories/Table.stories.tsx index 27558e39fb2..c01caba0f87 100644 --- a/packages/@react-spectrum/table/stories/Table.stories.tsx +++ b/packages/@react-spectrum/table/stories/Table.stories.tsx @@ -1253,6 +1253,7 @@ function useMountEffect(fn: () => void, deps: Array): void { } else { mounted.current = true; } + // eslint-disable-next-line react-hooks/exhaustive-deps }, deps); } @@ -1631,12 +1632,12 @@ function TableWithBreadcrumbs(props) { {key: 'd', name: 'File D', value: '10 MB', parent: 'a'} ]; - const [loadingState, setLoadingState] = useState('idle' as 'idle'); + const [loadingState, setLoadingState] = useState('idle' as const); const [selection, setSelection] = useState<'all' | Iterable>(new Set([])); const [items, setItems] = useState(() => fs.filter(item => !item.parent)); const changeFolder = (folder) => { setItems([]); - setLoadingState('loading' as 'loading'); + setLoadingState('loading' as const); // mimic loading behavior setTimeout(() => { @@ -2175,7 +2176,7 @@ function LoadingTable(): JSX.Element { setItems([]); setLoadingState('loading'); setTimeout(() => { - setItems(items.length > 1 ? [...items.slice(0, 1)] : []); + setItems(items.length > 1 ? items.slice(0, 1) : []); setLoadingState('idle'); }, 1000); }; diff --git a/packages/dev/s2-docs/src/VisualExampleClient.tsx b/packages/dev/s2-docs/src/VisualExampleClient.tsx index ca69b2d7b06..faf7d43485a 100644 --- a/packages/dev/s2-docs/src/VisualExampleClient.tsx +++ b/packages/dev/s2-docs/src/VisualExampleClient.tsx @@ -93,6 +93,7 @@ export function VisualExampleClient({component, name, importSource, controls, ch } setProps(newProps); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return ( diff --git a/packages/react-aria-components/src/HiddenDateInput.tsx b/packages/react-aria-components/src/HiddenDateInput.tsx index f0ce4b971b7..60fc3af948e 100644 --- a/packages/react-aria-components/src/HiddenDateInput.tsx +++ b/packages/react-aria-components/src/HiddenDateInput.tsx @@ -63,7 +63,7 @@ export function useHiddenDateInput(props: HiddenDateInputProps, state: DateField if (state.granularity === 'second') { inputStep = 1; } else if (state.granularity === 'hour') { - inputStep = 3600; + inputStep = 3600; } let dateValue = state.value == null ? '' : state.value.toString(); @@ -107,15 +107,17 @@ export function useHiddenDateInput(props: HiddenDateInputProps, state: DateField } // We check to to see if setSegment exists in the state since it only exists in DateFieldState and not DatePickerState. // The setValue method has different behavior depending on if it's coming from DateFieldState or DatePickerState. - // In DateFieldState, setValue firsts checks to make sure that each segment is filled before committing the newValue - // which is why in the code below we first set each segment to validate it before committing the new value. - // However, in DatePickerState, since we have to be able to commit values from the Calendar popover, we are also able to + // In DateFieldState, setValue firsts checks to make sure that each segment is filled before committing the newValue + // which is why in the code below we first set each segment to validate it before committing the new value. + // However, in DatePickerState, since we have to be able to commit values from the Calendar popover, we are also able to // set a new value when the field itself is empty. if ('setSegment' in state) { for (let type in targetValue) { + // eslint-disable-next-line max-depth if (dateSegments.includes(type)) { state.setSegment(type as DateSegmentType, targetValue[type]); } + // eslint-disable-next-line max-depth if (timeSegments.includes(type)) { state.setSegment(type as DateSegmentType, targetValue[type]); } diff --git a/packages/react-aria-components/test/Button.test.js b/packages/react-aria-components/test/Button.test.js index 08dda75820f..ef9a3dc34c1 100644 --- a/packages/react-aria-components/test/Button.test.js +++ b/packages/react-aria-components/test/Button.test.js @@ -321,6 +321,7 @@ describe('Button', () => { function TestComponent(props) { let [pending, setPending] = useState(false); return ( + // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
{ // forms are submitted implicitly on keydown, so we need to wait to set pending until after to set pending diff --git a/yarn.lock b/yarn.lock index 971f3098b1c..b59d47ec636 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15583,12 +15583,18 @@ __metadata: languageName: node linkType: hard -"eslint-plugin-react-hooks@npm:^5.0.0": - version: 5.0.0 - resolution: "eslint-plugin-react-hooks@npm:5.0.0" +"eslint-plugin-react-hooks@npm:^7.0.0": + version: 7.0.0 + resolution: "eslint-plugin-react-hooks@npm:7.0.0" + dependencies: + "@babel/core": "npm:^7.24.4" + "@babel/parser": "npm:^7.24.4" + hermes-parser: "npm:^0.25.1" + zod: "npm:^3.22.4 || ^4.0.0" + zod-validation-error: "npm:^3.0.3 || ^4.0.0" peerDependencies: eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 - checksum: 10c0/bcb74b421f32e4203a7100405b57aab85526be4461e5a1da01bc537969a30012d2ee209a2c2a6cac543833a27188ce1e6ad71e4628d0bb4a2e5365cad86c5002 + checksum: 10c0/911c9efdd9b102ce2eabac247dff8c217ecb8d6972aaf3b7eecfb1cfc293d4d902766355993ff7a37a33c0abde3e76971f43bc1c8ff36d6c123310e5680d0423 languageName: node linkType: hard @@ -17697,6 +17703,22 @@ __metadata: languageName: node linkType: hard +"hermes-estree@npm:0.25.1": + version: 0.25.1 + resolution: "hermes-estree@npm:0.25.1" + checksum: 10c0/48be3b2fa37a0cbc77a112a89096fa212f25d06de92781b163d67853d210a8a5c3784fac23d7d48335058f7ed283115c87b4332c2a2abaaccc76d0ead1a282ac + languageName: node + linkType: hard + +"hermes-parser@npm:^0.25.1": + version: 0.25.1 + resolution: "hermes-parser@npm:0.25.1" + dependencies: + hermes-estree: "npm:0.25.1" + checksum: 10c0/3abaa4c6f1bcc25273f267297a89a4904963ea29af19b8e4f6eabe04f1c2c7e9abd7bfc4730ddb1d58f2ea04b6fee74053d8bddb5656ec6ebf6c79cc8d14202c + languageName: node + linkType: hard + "hex-rgb@npm:^4.1.0": version: 4.3.0 resolution: "hex-rgb@npm:4.3.0" @@ -24627,7 +24649,7 @@ __metadata: eslint-plugin-jsdoc: "npm:^50.4.1" eslint-plugin-jsx-a11y: "npm:^6.10.0" eslint-plugin-react: "npm:^7.37.1" - eslint-plugin-react-hooks: "npm:^5.0.0" + eslint-plugin-react-hooks: "npm:^7.0.0" eslint-plugin-rulesdir: "npm:^0.2.2" fast-check: "npm:^2.19.0" fast-glob: "npm:^3.1.0" @@ -29549,6 +29571,22 @@ __metadata: languageName: node linkType: hard +"zod-validation-error@npm:^3.0.3 || ^4.0.0": + version: 4.0.2 + resolution: "zod-validation-error@npm:4.0.2" + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + checksum: 10c0/0ccfec48c46de1be440b719cd02044d4abb89ed0e14c13e637cd55bf29102f67ccdba373f25def0fc7130e5f15025be4d557a7edcc95d5a3811599aade689e1b + languageName: node + linkType: hard + +"zod@npm:^3.22.4 || ^4.0.0": + version: 4.1.12 + resolution: "zod@npm:4.1.12" + checksum: 10c0/b64c1feb19e99d77075261eaf613e0b2be4dfcd3551eff65ad8b4f2a079b61e379854d066f7d447491fcf193f45babd8095551a9d47973d30b46b6d8e2c46774 + languageName: node + linkType: hard + "zod@npm:^3.23.8": version: 3.25.76 resolution: "zod@npm:3.25.76"