diff --git a/packages/@react-stately/combobox/package.json b/packages/@react-stately/combobox/package.json index 1576fc7f23c..8d6664ff0e3 100644 --- a/packages/@react-stately/combobox/package.json +++ b/packages/@react-stately/combobox/package.json @@ -33,7 +33,8 @@ "@swc/helpers": "^0.5.0" }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" }, "publishConfig": { "access": "public" diff --git a/packages/@react-stately/form/src/useFormValidationState.ts b/packages/@react-stately/form/src/useFormValidationState.ts index 4dd785d6111..81d074d02b5 100644 --- a/packages/@react-stately/form/src/useFormValidationState.ts +++ b/packages/@react-stately/form/src/useFormValidationState.ts @@ -63,10 +63,20 @@ export interface FormValidationState { } export function useFormValidationState(props: FormValidationProps): FormValidationState { - // Private prop for parent components to pass state to children. - if (props[privateValidationStateProp]) { - let {realtimeValidation, displayValidation, updateValidation, resetValidation, commitValidation} = props[privateValidationStateProp] as FormValidationState; + const privateProp = props[privateValidationStateProp]; + + let privateState = useMemo(() => { + if (!privateProp) { + return null; + } + + let {realtimeValidation, displayValidation, updateValidation, resetValidation, commitValidation} = privateProp as FormValidationState; return {realtimeValidation, displayValidation, updateValidation, resetValidation, commitValidation}; + }, [privateProp]); + + // Private prop for parent components to pass state to children. + if (privateState) { + return privateState; } // eslint-disable-next-line react-hooks/rules-of-hooks @@ -152,7 +162,7 @@ function useFormValidationStateImpl(props: FormValidationProps): FormValid ? controlledError || serverError || currentValidity : controlledError || serverError || clientError || builtinValidation || currentValidity; - return { + return useMemo(() => ({ realtimeValidation, displayValidation, updateValidation(value) { @@ -188,7 +198,7 @@ function useFormValidationStateImpl(props: FormValidationProps): FormValid } setServerErrorCleared(true); } - }; + }), [realtimeValidation, displayValidation, validationBehavior, currentValidity]); } function asArray(v: T | T[]): T[] { diff --git a/packages/@react-stately/list/src/useListState.ts b/packages/@react-stately/list/src/useListState.ts index 15813f1dee4..ed214fe6cff 100644 --- a/packages/@react-stately/list/src/useListState.ts +++ b/packages/@react-stately/list/src/useListState.ts @@ -63,11 +63,11 @@ export function useListState(props: ListProps): ListState ({ collection, disabledKeys, selectionManager - }; + }), [collection, disabledKeys, selectionManager]); } /** @@ -75,13 +75,14 @@ export function useListState(props: ListProps): ListState(state: ListState, filter: ((nodeValue: string) => boolean) | null | undefined): ListState { let collection = useMemo(() => filter ? state.collection.UNSTABLE_filter!(filter) : state.collection, [state.collection, filter]); - let selectionManager = state.selectionManager.withCollection(collection); + let selectionManager = useMemo(() => state.selectionManager.withCollection(collection), [state, collection]); useFocusedKeyReset(collection, selectionManager); - return { + + return useMemo(() => ({ collection, - selectionManager, - disabledKeys: state.disabledKeys - }; + disabledKeys: state.disabledKeys, + selectionManager + }), [collection, state.disabledKeys, selectionManager]); } function useFocusedKeyReset(collection: Collection>, selectionManager: SelectionManager) { diff --git a/packages/@react-stately/list/src/useSingleSelectListState.ts b/packages/@react-stately/list/src/useSingleSelectListState.ts index b99a98af8ad..15b679a6547 100644 --- a/packages/@react-stately/list/src/useSingleSelectListState.ts +++ b/packages/@react-stately/list/src/useSingleSelectListState.ts @@ -12,8 +12,8 @@ import {CollectionStateBase, Key, Node, Selection, SingleSelection} from '@react-types/shared'; import {ListState, useListState} from './useListState'; +import {useCallback, useMemo} from 'react'; import {useControlledState} from '@react-stately/utils'; -import {useMemo} from 'react'; export interface SingleSelectListProps extends CollectionStateBase, Omit { /** Filter function to generate a filtered list of nodes. */ @@ -40,13 +40,14 @@ export interface SingleSelectListState extends ListState { export function useSingleSelectListState(props: SingleSelectListProps): SingleSelectListState { let [selectedKey, setSelectedKey] = useControlledState(props.selectedKey, props.defaultSelectedKey ?? null, props.onSelectionChange); let selectedKeys = useMemo(() => selectedKey != null ? [selectedKey] : [], [selectedKey]); + let onSelectionChange = props.onSelectionChange; let {collection, disabledKeys, selectionManager} = useListState({ ...props, selectionMode: 'single', disallowEmptySelection: true, allowDuplicateSelectionEvents: true, selectedKeys, - onSelectionChange: (keys: Selection) => { + onSelectionChange: useCallback((keys: Selection) => { // impossible, but TS doesn't know that if (keys === 'all') { return; @@ -55,24 +56,24 @@ export function useSingleSelectListState(props: SingleSelectLi // Always fire onSelectionChange, even if the key is the same // as the current key (useControlledState does not). - if (key === selectedKey && props.onSelectionChange) { - props.onSelectionChange(key); + if (key === selectedKey && onSelectionChange) { + onSelectionChange(key); } setSelectedKey(key); - } + }, [onSelectionChange, selectedKey, setSelectedKey]) }); let selectedItem = selectedKey != null ? collection.getItem(selectedKey) : null; - return { + return useMemo(() => ({ collection, disabledKeys, selectionManager, selectedKey, setSelectedKey, selectedItem - }; + }), [collection, disabledKeys, selectionManager, selectedKey, setSelectedKey, selectedItem]); } diff --git a/packages/@react-stately/overlays/src/useOverlayTriggerState.ts b/packages/@react-stately/overlays/src/useOverlayTriggerState.ts index 8e698afeea2..7220320a8e1 100644 --- a/packages/@react-stately/overlays/src/useOverlayTriggerState.ts +++ b/packages/@react-stately/overlays/src/useOverlayTriggerState.ts @@ -11,7 +11,7 @@ */ import {OverlayTriggerProps} from '@react-types/overlays'; -import {useCallback} from 'react'; +import {useCallback, useMemo} from 'react'; import {useControlledState} from '@react-stately/utils'; export interface OverlayTriggerState { @@ -46,11 +46,11 @@ export function useOverlayTriggerState(props: OverlayTriggerProps): OverlayTrigg setOpen(!isOpen); }, [setOpen, isOpen]); - return { + return useMemo(() => ({ isOpen, setOpen, open, close, toggle - }; + }), [isOpen, setOpen, open, close, toggle]); } diff --git a/packages/@react-stately/select/package.json b/packages/@react-stately/select/package.json index 175b257188f..9d802b72e8d 100644 --- a/packages/@react-stately/select/package.json +++ b/packages/@react-stately/select/package.json @@ -22,6 +22,7 @@ "url": "https://github.com/adobe/react-spectrum" }, "dependencies": { + "@react-aria/utils": "^3.28.1", "@react-stately/form": "^3.1.2", "@react-stately/list": "^3.12.0", "@react-stately/overlays": "^3.6.14", @@ -30,7 +31,8 @@ "@swc/helpers": "^0.5.0" }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" }, "publishConfig": { "access": "public" diff --git a/packages/@react-stately/select/src/useSelectState.ts b/packages/@react-stately/select/src/useSelectState.ts index 3aff970197e..b70c8598276 100644 --- a/packages/@react-stately/select/src/useSelectState.ts +++ b/packages/@react-stately/select/src/useSelectState.ts @@ -15,7 +15,8 @@ import {FormValidationState, useFormValidationState} from '@react-stately/form'; import {OverlayTriggerState, useOverlayTriggerState} from '@react-stately/overlays'; import {SelectProps} from '@react-types/select'; import {SingleSelectListState, useSingleSelectListState} from '@react-stately/list'; -import {useState} from 'react'; +import {useCallback, useMemo, useRef, useState} from 'react'; +import {useLayoutEffect} from '@react-aria/utils'; export interface SelectStateOptions extends Omit, 'children'>, CollectionStateBase {} @@ -44,26 +45,35 @@ export interface SelectState extends SingleSelectListState, OverlayTrigger export function useSelectState(props: SelectStateOptions): SelectState { let triggerState = useOverlayTriggerState(props); let [focusStrategy, setFocusStrategy] = useState(null); + + // This is necessary to circumvent a circular dependency below + let validationStateRef = useRef(null); + + let onSelectionChange = props.onSelectionChange; let listState = useSingleSelectListState({ ...props, - onSelectionChange: (key) => { - if (props.onSelectionChange != null) { - props.onSelectionChange(key); + onSelectionChange: useCallback(key => { + if (onSelectionChange != null) { + onSelectionChange(key); } triggerState.close(); - validationState.commitValidation(); - } + validationStateRef.current!.commitValidation(); + }, [onSelectionChange, triggerState]) }); let validationState = useFormValidationState({ ...props, value: listState.selectedKey }); + useLayoutEffect(() => { + validationStateRef.current = validationState; + }, [validationState]); + let [isFocused, setFocused] = useState(false); - return { + return useMemo(() => ({ ...validationState, ...listState, ...triggerState, @@ -83,5 +93,5 @@ export function useSelectState(props: SelectStateOptions): }, isFocused, setFocused - }; + }), [validationState, listState, triggerState, focusStrategy, isFocused]); } diff --git a/packages/@react-stately/selection/src/useMultipleSelectionState.ts b/packages/@react-stately/selection/src/useMultipleSelectionState.ts index e58d0823850..9e3335b4139 100644 --- a/packages/@react-stately/selection/src/useMultipleSelectionState.ts +++ b/packages/@react-stately/selection/src/useMultipleSelectionState.ts @@ -85,7 +85,7 @@ export function useMultipleSelectionState(props: MultipleSelectionStateProps): M } }, [selectionBehaviorProp]); - return { + return useMemo(() => ({ selectionMode, disallowEmptySelection, selectionBehavior, @@ -116,7 +116,7 @@ export function useMultipleSelectionState(props: MultipleSelectionStateProps): M }, disabledKeys: disabledKeysProp, disabledBehavior - }; + }), [selectionMode, disallowEmptySelection, selectionBehavior, selectedKeys, setSelectedKeys, allowDuplicateSelectionEvents, disabledKeysProp, disabledBehavior]); } function convertSelection(selection: 'all' | Iterable | null | undefined, defaultValue?: Selection): 'all' | Set | undefined { diff --git a/packages/react-aria-components/docs/TagGroup.mdx b/packages/react-aria-components/docs/TagGroup.mdx index 0868156df51..a60580190c0 100644 --- a/packages/react-aria-components/docs/TagGroup.mdx +++ b/packages/react-aria-components/docs/TagGroup.mdx @@ -707,9 +707,14 @@ import {ListStateContext} from 'react-aria-components'; function SelectionCount() { /*- begin highlight -*/ - let state = React.useContext(ListStateContext); + let context = React.useContext(ListStateContext); /*- end highlight -*/ - let selected = state?.selectionManager.selectedKeys.size ?? 0; + let selected = 0; + if(context) { + let [state] = context; + selected = state.selectionManager.selectedKeys.size; + } + return {selected} tags selected.; } diff --git a/packages/react-aria-components/src/ComboBox.tsx b/packages/react-aria-components/src/ComboBox.tsx index 2f846289e41..2a0e76ffb2a 100644 --- a/packages/react-aria-components/src/ComboBox.tsx +++ b/packages/react-aria-components/src/ComboBox.tsx @@ -200,7 +200,7 @@ function ComboBoxInner({props, collection, comboBoxRef: ref}: style: {'--trigger-width': menuWidth} as React.CSSProperties }], [ListBoxContext, {...listBoxProps, ref: listBoxRef}], - [ListStateContext, state], + [ListStateContext, [state, state.selectionManager.focusedKey]], [TextContext, { slots: { description: descriptionProps, diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index 86c29a79b97..dc7a29d5fe6 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -237,7 +237,7 @@ function GridListInner({props, collection, gridListRef: ref}: data-layout={layout}> @@ -277,7 +277,7 @@ export interface GridListItemProps extends RenderProps(props: GridListItemProps, forwardedRef: ForwardedRef, item: Node) { - let state = useContext(ListStateContext)!; + let [state] = useContext(ListStateContext)!; let {dragAndDropHooks, dragState, dropState} = useContext(DragAndDropContext); let ref = useObjectRef(forwardedRef); let {isVirtualized} = useContext(CollectionRendererContext); diff --git a/packages/react-aria-components/src/ListBox.tsx b/packages/react-aria-components/src/ListBox.tsx index 246c9d9fc19..6c8e2f9b794 100644 --- a/packages/react-aria-components/src/ListBox.tsx +++ b/packages/react-aria-components/src/ListBox.tsx @@ -20,7 +20,7 @@ import {DraggableCollectionState, DroppableCollectionState, ListState, Node, Ori import {filterDOMProps, mergeRefs, useObjectRef} from '@react-aria/utils'; import {forwardRefType, HoverEvents, Key, LinkDOMProps, RefObject} from '@react-types/shared'; import {HeaderContext} from './Header'; -import React, {createContext, ForwardedRef, forwardRef, JSX, ReactNode, useContext, useEffect, useMemo, useRef} from 'react'; +import React, {createContext, ForwardedRef, forwardRef, JSX, memo, ReactNode, useContext, useEffect, useMemo, useRef} from 'react'; import {SeparatorContext} from './Separator'; import {TextContext} from './Text'; import {UNSTABLE_InternalAutocompleteContext} from './Autocomplete'; @@ -78,14 +78,14 @@ export interface ListBoxProps extends Omit, 'children' | } export const ListBoxContext = createContext, HTMLDivElement>>(null); -export const ListStateContext = createContext | null>(null); +export const ListStateContext = createContext<[ListState, Key | null] | null>(null); /** * A listbox displays a list of options and allows a user to select one or more of them. */ export const ListBox = /*#__PURE__*/ (forwardRef as forwardRefType)(function ListBox(props: ListBoxProps, ref: ForwardedRef) { [props, ref] = useContextProps(props, ref, ListBoxContext); - let state = useContext(ListStateContext); + let context = useContext(ListStateContext); // The structure of ListBox is a bit strange because it needs to work inside other components like ComboBox and Select. // Those components render two copies of their children so that the collection can be built even when the popover is closed. @@ -93,7 +93,9 @@ export const ListBox = /*#__PURE__*/ (forwardRef as forwardRefType)(function Lis // The second copy sends a ListState object via context which we use to render the ListBox without rebuilding the state. // Otherwise, we have a standalone ListBox, so we need to create a collection and state ourselves. - if (state) { + if (context) { + let [state] = context; + return ; } @@ -248,8 +250,8 @@ function ListBoxInner({state: inputState, props, listBoxRef}: ({dragAndDropHooks, dragState, dropState}), [dragAndDropHooks, dragState, dropState])], [SeparatorContext, {elementType: 'div'}], [DropIndicatorContext, {render: ListBoxDropIndicatorWrapper}], [SectionContext, {name: 'ListBoxSection', render: ListBoxSectionInner}] @@ -270,7 +272,7 @@ function ListBoxInner({state: inputState, props, listBoxRef}: export interface ListBoxSectionProps extends SectionProps {} function ListBoxSectionInner(props: ListBoxSectionProps, ref: ForwardedRef, section: Node, className = 'react-aria-ListBoxSection') { - let state = useContext(ListStateContext)!; + let [state] = useContext(ListStateContext)!; let {dragAndDropHooks, dropState} = useContext(DragAndDropContext)!; let {CollectionBranch} = useContext(CollectionRendererContext); let [headingRef, heading] = useSlot(); @@ -331,14 +333,26 @@ export interface ListBoxItemProps extends RenderProps(props: ListBoxItemProps, forwardedRef: ForwardedRef, item: Node) { let ref = useObjectRef(forwardedRef); - let state = useContext(ListStateContext)!; + let [state, focusedKey] = useContext(ListStateContext)!; + + // ListBoxItemInner is memoized so that a focus change does not re-render all items in the ListBox. + // The data-focused attribute tells React which list boxes are affected by a focus change. It does not actually + // get passed through to the component - it could be named anything, as long as it changes so React knows to re-render. + return ; +}); + +const ListBoxItemInner = memo(function ListBoxItemInner({props, item, state, passRef}: {props: ListBoxItemProps, state: ListState, item: Node, passRef: React.MutableRefObject}) { + const ref = passRef; + let {dragAndDropHooks, dragState, dropState} = useContext(DragAndDropContext)!; - let {optionProps, labelProps, descriptionProps, ...states} = useOption( + let options = useOption( {key: item.key, 'aria-label': props?.['aria-label']}, state, ref ); + let {optionProps, labelProps, descriptionProps, ...states} = options; + let {hoverProps, isHovered} = useHover({ isDisabled: !states.allowsSelection && !states.hasAction, onHoverStart: item.props.onHoverStart, diff --git a/packages/react-aria-components/src/Select.tsx b/packages/react-aria-components/src/Select.tsx index 35613d14b5e..5ac6fffddfc 100644 --- a/packages/react-aria-components/src/Select.tsx +++ b/packages/react-aria-components/src/Select.tsx @@ -183,7 +183,7 @@ function SelectInner({props, selectRef: ref, collection}: Sele 'aria-labelledby': menuProps['aria-labelledby'] }], [ListBoxContext, {...menuProps, ref: scrollRef}], - [ListStateContext, state], + [ListStateContext, [state, state.selectionManager.focusedKey]], [TextContext, { slots: { description: descriptionProps, diff --git a/packages/react-aria-components/src/TagGroup.tsx b/packages/react-aria-components/src/TagGroup.tsx index 0f7d1ff4827..72545cf6417 100644 --- a/packages/react-aria-components/src/TagGroup.tsx +++ b/packages/react-aria-components/src/TagGroup.tsx @@ -109,7 +109,7 @@ function TagGroupInner({props, forwardedRef: ref, collection}: TagGroupInnerProp values={[ [LabelContext, {...labelProps, elementType: 'span', ref: labelRef}], [TagListContext, {...gridProps, ref: tagListRef}], - [ListStateContext, state], + [ListStateContext, [state, state.selectionManager.focusedKey]], [TextContext, { slots: { description: descriptionProps, @@ -139,7 +139,7 @@ interface TagListInnerProps { } function TagListInner({props, forwardedRef}: TagListInnerProps) { - let state = useContext(ListStateContext)!; + let [state] = useContext(ListStateContext)!; let {CollectionRoot} = useContext(CollectionRendererContext); let [gridProps, ref] = useContextProps(props, forwardedRef, TagListContext); delete gridProps.items; @@ -200,7 +200,7 @@ export interface TagProps extends RenderProps, LinkDOMProps, Hov * A Tag is an individual item within a TagList. */ export const Tag = /*#__PURE__*/ createLeafComponent('item', (props: TagProps, forwardedRef: ForwardedRef, item: Node) => { - let state = useContext(ListStateContext)!; + let [state] = useContext(ListStateContext)!; let ref = useObjectRef(forwardedRef); let {focusProps, isFocusVisible} = useFocusRing({within: true}); let {rowProps, gridCellProps, removeButtonProps, ...states} = useTag({item}, state, ref); diff --git a/packages/react-stately/package.json b/packages/react-stately/package.json index 8cdda971cd5..4be0f0e3768 100644 --- a/packages/react-stately/package.json +++ b/packages/react-stately/package.json @@ -52,7 +52,8 @@ "@react-types/shared": "^3.28.0" }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" }, "devDependencies": { "@babel/cli": "^7.24.1", diff --git a/yarn.lock b/yarn.lock index b98821a6f65..39be3589dcf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8550,6 +8550,7 @@ __metadata: "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 languageName: unknown linkType: soft @@ -8738,6 +8739,7 @@ __metadata: version: 0.0.0-use.local resolution: "@react-stately/select@workspace:packages/@react-stately/select" dependencies: + "@react-aria/utils": "npm:^3.28.1" "@react-stately/form": "npm:^3.1.2" "@react-stately/list": "npm:^3.12.0" "@react-stately/overlays": "npm:^3.6.14" @@ -8746,6 +8748,7 @@ __metadata: "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 languageName: unknown linkType: soft @@ -29692,6 +29695,7 @@ __metadata: "@react-types/shared": "npm:^3.28.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 languageName: unknown linkType: soft