From fd0960bf0a1822018c5f11914b01b366910ac4a5 Mon Sep 17 00:00:00 2001 From: wanxiankai Date: Tue, 14 Jul 2026 10:44:49 +0800 Subject: [PATCH 1/3] fix: bubble Select change events to forms --- .../react-aria-components/test/Select.test.js | 91 +++++++++++++++++++ .../react-aria/src/select/HiddenSelect.tsx | 44 ++++++++- .../src/select/useSelectState.ts | 21 ++++- 3 files changed, 151 insertions(+), 5 deletions(-) diff --git a/packages/react-aria-components/test/Select.test.js b/packages/react-aria-components/test/Select.test.js index df4906037d2..d806488ff5a 100644 --- a/packages/react-aria-components/test/Select.test.js +++ b/packages/react-aria-components/test/Select.test.js @@ -95,6 +95,97 @@ describe('Select', () => { expect(trigger).toHaveTextContent('Dog'); }); + it('should bubble a change event from the hidden select when the selection changes', async () => { + let onChange = jest.fn(); + let {getByTestId} = render( +
+ + + ); + let wrapper = getByTestId('select'); + let selectTester = testUtilUser.createTester('Select', {root: wrapper}); + let hiddenSelect = document.querySelector('select[name="animal"]'); + + await selectTester.toggleOptionSelection({option: 'Dog'}); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange.mock.calls[0][0].target).toBe(hiddenSelect); + expect(hiddenSelect).toHaveValue('dog'); + }); + + it('should not duplicate change events from the hidden select', async () => { + let onChange = jest.fn(); + render( +
+ + + ); + let hiddenSelect = document.querySelector('select[name="animal"]'); + + await user.selectOptions(hiddenSelect, 'dog'); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange.mock.calls[0][0].target).toBe(hiddenSelect); + expect(hiddenSelect).toHaveValue('dog'); + }); + + it('should bubble a change event for a controlled select', async () => { + let onChange = jest.fn(); + let values = []; + let {getByTestId} = render( +
values.push(e.target.value)}> + + + ); + let wrapper = getByTestId('select'); + let selectTester = testUtilUser.createTester('Select', {root: wrapper}); + let hiddenSelect = document.querySelector('select[name="animal"]'); + + await selectTester.toggleOptionSelection({option: 'Dog'}); + + expect(onChange).toHaveBeenCalledWith('dog'); + expect(values).toEqual(['dog']); + expect(hiddenSelect).toHaveValue('cat'); + }); + + it('should not bubble a change event when the form is reset', async () => { + let onChange = jest.fn(); + let formRef = React.createRef(); + let {getByTestId} = render( +
+ + + ); + let wrapper = getByTestId('select'); + let selectTester = testUtilUser.createTester('Select', {root: wrapper}); + let hiddenSelect = document.querySelector('select[name="animal"]'); + + await selectTester.toggleOptionSelection({option: 'Dog'}); + expect(onChange).toHaveBeenCalledTimes(1); + onChange.mockClear(); + + act(() => formRef.current.reset()); + + expect(onChange).not.toHaveBeenCalled(); + expect(hiddenSelect).toHaveValue('cat'); + }); + + it('should bubble change events with the selected options for multiple selection', async () => { + let values = []; + let {getByTestId} = render( +
values.push([...e.target.selectedOptions].map(option => option.value))}> + + + ); + let wrapper = getByTestId('select'); + let selectTester = testUtilUser.createTester('Select', {root: wrapper}); + + await selectTester.toggleOptionSelection({option: 'Cat'}); + await selectTester.toggleOptionSelection({option: 'Dog'}); + + expect(values).toEqual([['cat'], ['cat', 'dog']]); + }); + it('should support slot', () => { let {getByTestId} = render( diff --git a/packages/react-aria/src/select/HiddenSelect.tsx b/packages/react-aria/src/select/HiddenSelect.tsx index fee8d126111..44b25efc0d3 100644 --- a/packages/react-aria/src/select/HiddenSelect.tsx +++ b/packages/react-aria/src/select/HiddenSelect.tsx @@ -12,7 +12,7 @@ import {FocusableElement, Key, RefObject} from '@react-types/shared'; import {getEventTarget} from '../utils/shadowdom/DOMFunctions'; -import React, {InputHTMLAttributes, JSX, ReactNode, useCallback, useRef} from 'react'; +import React, {InputHTMLAttributes, JSX, ReactNode, useCallback, useEffect, useRef} from 'react'; import {selectData} from './useSelect'; import {SelectionMode, SelectState} from 'react-stately/useSelectState'; import {useFormReset} from '../utils/useFormReset'; @@ -103,18 +103,56 @@ export function useHiddenSelect( ); let setValue = state.setValue; + let subscribeToValueChange = state.subscribeToValueChange; + let isDispatchingChange = useRef(false); let onChange = useCallback( (e: React.ChangeEvent) => { + if (isDispatchingChange.current) { + return; + } + let eventTarget = getEventTarget(e); + let value: string | string[]; if (eventTarget.multiple) { - setValue(Array.from(eventTarget.selectedOptions, option => option.value) as any); + value = Array.from(eventTarget.selectedOptions, option => option.value); } else { - setValue(e.currentTarget.value as any); + value = e.currentTarget.value; } + + setValue(value as any); }, [setValue] ); + useEffect(() => { + return subscribeToValueChange(value => { + let select = props.selectRef?.current; + if (!(select instanceof HTMLSelectElement)) { + return; + } + + if (select.multiple) { + let values = new Set((Array.isArray(value) ? value : [value]).map(String)); + for (let option of select.options) { + option.selected = values.has(option.value); + } + } else { + let valueSetter = Object.getOwnPropertyDescriptor( + Object.getPrototypeOf(select), + 'value' + )?.set; + valueSetter?.call(select, String(Array.isArray(value) ? (value[0] ?? '') : (value ?? ''))); + } + + isDispatchingChange.current = true; + try { + select.dispatchEvent(new Event('change', {bubbles: true})); + } finally { + isDispatchingChange.current = false; + } + }); + }, [props.selectRef, subscribeToValueChange]); + // In Safari, the whereas other browsers // seem to identify it just by surrounding text. diff --git a/packages/react-stately/src/select/useSelectState.ts b/packages/react-stately/src/select/useSelectState.ts index 417189dfb91..1966d072d19 100644 --- a/packages/react-stately/src/select/useSelectState.ts +++ b/packages/react-stately/src/select/useSelectState.ts @@ -28,8 +28,8 @@ import { import {FormValidationState, useFormValidationState} from '../form/useFormValidationState'; import {ListState, useListState} from '../list/useListState'; import {OverlayTriggerState, useOverlayTriggerState} from '../overlays/useOverlayTriggerState'; +import {useCallback, useMemo, useRef, useState} from 'react'; import {useControlledState} from '../utils/useControlledState'; -import {useMemo, useState} from 'react'; export type SelectionMode = 'single' | 'multiple'; export type ValueType = M extends 'single' ? Key | null : readonly Key[]; @@ -120,6 +120,9 @@ export interface SelectState /** Sets the select value. */ setValue(value: Key | readonly Key[] | null): void; + /** Registers a listener that is called when the user changes the select value. */ + subscribeToValueChange(listener: (value: ChangeValueType) => void): () => void; + /** * The value of the first selected item. * @@ -177,6 +180,16 @@ export function useSelectState( selectionMode === 'single' && Array.isArray(controlledValue) ? controlledValue[0] : controlledValue; + let valueChangeListeners = useRef(new Set<(value: ChangeValueType) => void>()); + let subscribeToValueChange = useCallback((listener: (value: ChangeValueType) => void) => { + valueChangeListeners.current.add(listener); + return () => valueChangeListeners.current.delete(listener); + }, []); + let emitValueChange = useCallback((value: ChangeValueType) => { + for (let listener of valueChangeListeners.current) { + listener(value); + } + }, []); let setValue = (value: Key | Key[] | null) => { if (selectionMode === 'single') { let key = Array.isArray(value) ? (value[0] ?? null) : value; @@ -212,8 +225,11 @@ export function useSelectState( if (selectionMode === 'single') { let key = keys.values().next().value ?? null; setValue(key); + emitValueChange(key as ChangeValueType); } else { - setValue([...keys]); + let value = [...keys]; + setValue(value); + emitValueChange(value as ChangeValueType); } if (shouldCloseOnSelect) { triggerState.close(); @@ -245,6 +261,7 @@ export function useSelectState( value: displayValue as ValueType, defaultValue: defaultValue ?? (initialValue as ValueType), setValue, + subscribeToValueChange, selectedKey, setSelectedKey: setValue, selectedItem: selectedItems[0] ?? null, From 579b754aa58fd3be7723da4c8ac53ae5cb9c2133 Mon Sep 17 00:00:00 2001 From: wanxiankai Date: Tue, 14 Jul 2026 11:09:13 +0800 Subject: [PATCH 2/3] test: support React 16 event pooling --- packages/react-aria-components/test/Select.test.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/react-aria-components/test/Select.test.js b/packages/react-aria-components/test/Select.test.js index d806488ff5a..383e91ff4ce 100644 --- a/packages/react-aria-components/test/Select.test.js +++ b/packages/react-aria-components/test/Select.test.js @@ -98,7 +98,7 @@ describe('Select', () => { it('should bubble a change event from the hidden select when the selection changes', async () => { let onChange = jest.fn(); let {getByTestId} = render( -
+ onChange(e.target)}> ); @@ -109,14 +109,14 @@ describe('Select', () => { await selectTester.toggleOptionSelection({option: 'Dog'}); expect(onChange).toHaveBeenCalledTimes(1); - expect(onChange.mock.calls[0][0].target).toBe(hiddenSelect); + expect(onChange).toHaveBeenCalledWith(hiddenSelect); expect(hiddenSelect).toHaveValue('dog'); }); it('should not duplicate change events from the hidden select', async () => { let onChange = jest.fn(); render( -
+ onChange(e.target)}> ); @@ -125,7 +125,7 @@ describe('Select', () => { await user.selectOptions(hiddenSelect, 'dog'); expect(onChange).toHaveBeenCalledTimes(1); - expect(onChange.mock.calls[0][0].target).toBe(hiddenSelect); + expect(onChange).toHaveBeenCalledWith(hiddenSelect); expect(hiddenSelect).toHaveValue('dog'); }); From b4282f75d9d3488bcbd657f038099e896aeecd69 Mon Sep 17 00:00:00 2001 From: wanxiankai Date: Tue, 14 Jul 2026 19:31:51 +0800 Subject: [PATCH 3/3] fix: handle Select change events for large collections --- .../react-aria-components/test/Select.test.js | 30 +++- .../react-aria/src/select/HiddenSelect.tsx | 138 +++++++----------- .../test/select/HiddenSelect.test.tsx | 17 +++ 3 files changed, 93 insertions(+), 92 deletions(-) diff --git a/packages/react-aria-components/test/Select.test.js b/packages/react-aria-components/test/Select.test.js index 383e91ff4ce..a14bfb2cbce 100644 --- a/packages/react-aria-components/test/Select.test.js +++ b/packages/react-aria-components/test/Select.test.js @@ -95,7 +95,7 @@ describe('Select', () => { expect(trigger).toHaveTextContent('Dog'); }); - it('should bubble a change event from the hidden select when the selection changes', async () => { + it('should bubble a change event from the hidden select when a ListBox option is selected', async () => { let onChange = jest.fn(); let {getByTestId} = render(
onChange(e.target)}> @@ -113,7 +113,7 @@ describe('Select', () => { expect(hiddenSelect).toHaveValue('dog'); }); - it('should not duplicate change events from the hidden select', async () => { + it('should bubble a native change event from the hidden select only once', async () => { let onChange = jest.fn(); render( onChange(e.target)}> @@ -132,9 +132,24 @@ describe('Select', () => { it('should bubble a change event for a controlled select', async () => { let onChange = jest.fn(); let values = []; + + function ControlledSelect() { + let [value, setValue] = useState('cat'); + return ( + { + onChange(value); + setValue(value); + }} + /> + ); + } + let {getByTestId} = render( values.push(e.target.value)}> - + ); let wrapper = getByTestId('select'); @@ -145,7 +160,7 @@ describe('Select', () => { expect(onChange).toHaveBeenCalledWith('dog'); expect(values).toEqual(['dog']); - expect(hiddenSelect).toHaveValue('cat'); + expect(hiddenSelect).toHaveValue('dog'); }); it('should not bubble a change event when the form is reset', async () => { @@ -839,12 +854,16 @@ describe('Select', () => { it('should support multiple selection form integration with many items', async () => { let items = []; + let values = []; for (let i = 0; i < 320; i++) { items.push({id: i, name: 'item' + i}); } let {getByTestId} = render( -
e.preventDefault()}> + values.push([...e.target.selectedOptions].map(option => option.value))} + onSubmit={e => e.preventDefault()}> element for this so that browser - // autofill will work. Otherwise, use an . - if (state.collection.size <= 300) { - return ( -
- -
- ); - } else if (name) { - let data = selectData.get(state) || {}; - let {validationBehavior} = data; - - // Always render at least one hidden input to ensure required form submission. - if (values.length === 0) { - values = [null]; - } - - let res = values.map((value, i) => { - let inputProps: InputHTMLAttributes = { - type: 'hidden', - autoComplete: selectProps.autoComplete, - name, - form, - disabled: isDisabled, - value: value ?? '' - }; - - if (validationBehavior === 'native') { - // Use a hidden rather than - // so that an empty value blocks HTML form submission when the field is required. - return ( - { - /** Ignore react warning. */ - }} - /> - ); - } - - return ; - }); - - return <>{res}; - } - - return null; + return ( +
+ +
+ ); } diff --git a/packages/react-aria/test/select/HiddenSelect.test.tsx b/packages/react-aria/test/select/HiddenSelect.test.tsx index 72738c25bcb..fb22237fd78 100644 --- a/packages/react-aria/test/select/HiddenSelect.test.tsx +++ b/packages/react-aria/test/select/HiddenSelect.test.tsx @@ -57,6 +57,23 @@ describe('', () => { ); }); + it('should only render selected options for collection.size > 300', () => { + render( + + ); + + let select = screen.getByLabelText('select') as HTMLSelectElement; + expect(select).toHaveValue('5'); + expect(select.options).toHaveLength(2); + }); + it('should have form value after initial render', async () => { let formRef = React.createRef(); render(