diff --git a/packages/@react-aria/calendar/src/useCalendarBase.ts b/packages/@react-aria/calendar/src/useCalendarBase.ts index 3c7ba57b9d6..7f52f43ee63 100644 --- a/packages/@react-aria/calendar/src/useCalendarBase.ts +++ b/packages/@react-aria/calendar/src/useCalendarBase.ts @@ -16,7 +16,7 @@ import {AriaLabelingProps, DOMAttributes} from '@react-types/shared'; import {CalendarPropsBase} from '@react-types/calendar'; import {CalendarState, RangeCalendarState} from '@react-stately/calendar'; import {DOMProps} from '@react-types/shared'; -import {filterDOMProps, mergeProps, useLabels, useSlotId, useUpdateEffect} from '@react-aria/utils'; +import {filterDOMProps, mergeProps, useLabels, useLayoutEffect, useSlotId, useUpdateEffect} from '@react-aria/utils'; import {hookData, useSelectedDateDescription, useVisibleRangeDescription} from './utils'; // @ts-ignore import intlMessages from '../intl/*.json'; @@ -70,20 +70,22 @@ export function useCalendarBase(props: CalendarPropsBase & DOMProps & AriaLabeli selectedDateDescription }); - // If the next or previous buttons become disabled while they are focused, move focus to the calendar body. let nextFocused = useRef(false); let nextDisabled = props.isDisabled || state.isNextVisibleRangeInvalid(); - if (nextDisabled && nextFocused.current) { - nextFocused.current = false; - state.setFocused(true); - } - let previousFocused = useRef(false); let previousDisabled = props.isDisabled || state.isPreviousVisibleRangeInvalid(); - if (previousDisabled && previousFocused.current) { - previousFocused.current = false; - state.setFocused(true); - } + // If the next or previous buttons become disabled while they are focused, move focus to the calendar body. + useLayoutEffect(() => { + if (nextDisabled && nextFocused.current) { + nextFocused.current = false; + state.setFocused(true); + } + + if (previousDisabled && previousFocused.current) { + previousFocused.current = false; + state.setFocused(true); + } + }, [nextDisabled, previousDisabled, state]); let labelProps = useLabels({ id: props['id'], diff --git a/packages/@react-aria/calendar/src/useCalendarCell.ts b/packages/@react-aria/calendar/src/useCalendarCell.ts index 6d93e064bf3..9996cd2c91e 100644 --- a/packages/@react-aria/calendar/src/useCalendarCell.ts +++ b/packages/@react-aria/calendar/src/useCalendarCell.ts @@ -13,7 +13,12 @@ import {CalendarDate, isEqualDay, isSameDay, isToday} from '@internationalized/date'; import {CalendarState, RangeCalendarState} from '@react-stately/calendar'; import {DOMAttributes} from '@react-types/shared'; -import {focusWithoutScrolling, getScrollParent, scrollIntoViewport, useDescription} from '@react-aria/utils'; +import { + focusWithoutScrolling, + getScrollParent, + scrollIntoViewport, + useDescription +} from '@react-aria/utils'; import {getEraFormat, hookData} from './utils'; import {getInteractionModality, usePress} from '@react-aria/interactions'; // @ts-ignore diff --git a/packages/@react-aria/calendar/src/useCalendarGrid.ts b/packages/@react-aria/calendar/src/useCalendarGrid.ts index 671924b2bbb..14a5189ced7 100644 --- a/packages/@react-aria/calendar/src/useCalendarGrid.ts +++ b/packages/@react-aria/calendar/src/useCalendarGrid.ts @@ -123,6 +123,7 @@ export function useCalendarGrid(props: AriaCalendarGridProps, state: CalendarSta let visibleRangeDescription = useVisibleRangeDescription(startDate, endDate, state.timeZone, true); let {ariaLabel, ariaLabelledBy} = hookData.get(state); + let labelProps = useLabels({ 'aria-label': [ariaLabel, visibleRangeDescription].filter(Boolean).join(', '), 'aria-labelledby': ariaLabelledBy diff --git a/packages/@react-aria/focus/src/useFocusRing.ts b/packages/@react-aria/focus/src/useFocusRing.ts index 6e813ba6294..d0ea4efc528 100644 --- a/packages/@react-aria/focus/src/useFocusRing.ts +++ b/packages/@react-aria/focus/src/useFocusRing.ts @@ -73,7 +73,7 @@ export function useFocusRing(props: AriaFocusRingProps = {}): FocusRingAria { return { isFocused, - isFocusVisible: state.current.isFocused && isFocusVisibleState, + isFocusVisible: isFocusVisibleState, focusProps: within ? focusWithinProps : focusProps }; } diff --git a/packages/@react-aria/i18n/src/useDateFormatter.ts b/packages/@react-aria/i18n/src/useDateFormatter.ts index cbf63e35457..4b2f1c956e5 100644 --- a/packages/@react-aria/i18n/src/useDateFormatter.ts +++ b/packages/@react-aria/i18n/src/useDateFormatter.ts @@ -12,7 +12,7 @@ import {DateFormatter} from '@internationalized/date'; import {useLocale} from './context'; -import {useMemo, useRef} from 'react'; +import {useMemo, useState} from 'react'; export interface DateFormatterOptions extends Intl.DateTimeFormatOptions { calendar?: string @@ -24,16 +24,18 @@ export interface DateFormatterOptions extends Intl.DateTimeFormatOptions { * @param options - Formatting options. */ export function useDateFormatter(options?: DateFormatterOptions): DateFormatter { + let {locale} = useLocale(); + // Reuse last options object if it is shallowly equal, which allows the useMemo result to also be reused. - let lastOptions = useRef(null); - if (options && lastOptions.current && isEqual(options, lastOptions.current)) { - options = lastOptions.current; + let [stabilizedOptions, setStabilizedOptions] = useState(options); + if (!stabilizedOptions || (options && !isEqual(stabilizedOptions, options))) { + // early render bail because setState in render + setStabilizedOptions(options); } - lastOptions.current = options; + let formattedDate = useMemo(() => new DateFormatter(locale, stabilizedOptions), [locale, stabilizedOptions]); - let {locale} = useLocale(); - return useMemo(() => new DateFormatter(locale, options), [locale, options]); + return formattedDate; } function isEqual(a: DateFormatterOptions, b: DateFormatterOptions) { diff --git a/packages/@react-aria/interactions/src/utils.ts b/packages/@react-aria/interactions/src/utils.ts index 80e0099223f..82411770e9f 100644 --- a/packages/@react-aria/interactions/src/utils.ts +++ b/packages/@react-aria/interactions/src/utils.ts @@ -64,13 +64,12 @@ export class SyntheticFocusEvent implements ReactFocusEvent(onBlur: (e: ReactFocusEvent) => void) { let stateRef = useRef({ isFocused: false, - onBlur, - observer: null as MutationObserver + observer: null as MutationObserver, + target: null as Element, + listener: null as (e: FocusEvent) => void }); - stateRef.current.onBlur = onBlur; // Clean up MutationObserver on unmount. See below. - // eslint-disable-next-line arrow-body-style useLayoutEffect(() => { const state = stateRef.current; return () => { @@ -78,6 +77,12 @@ export function useSyntheticBlurEvent(onBlur: (e: ReactFocusEv state.observer.disconnect(); state.observer = null; } + if (state.target && state.listener) { + state.target.removeEventListener('focusout', state.listener); + } + state.target = null; + state.listener = null; + state.isFocused = false; }; }, []); @@ -101,7 +106,7 @@ export function useSyntheticBlurEvent(onBlur: (e: ReactFocusEv if (target.disabled) { // For backward compatibility, dispatch a (fake) React synthetic event. - stateRef.current.onBlur?.(new SyntheticFocusEvent('blur', e)); + onBlur?.(new SyntheticFocusEvent('blur', e)); } // We no longer need the MutationObserver once the target is blurred. @@ -111,10 +116,19 @@ export function useSyntheticBlurEvent(onBlur: (e: ReactFocusEv } }; + if (stateRef.current.target && stateRef.current.listener) { + stateRef.current.target.removeEventListener('focusout', stateRef.current.listener); + } target.addEventListener('focusout', onBlurHandler, {once: true}); + stateRef.current.target = target; + stateRef.current.listener = onBlurHandler; + if (stateRef.current.observer) { + stateRef.current.observer.disconnect(); + stateRef.current.observer = null; + } stateRef.current.observer = new MutationObserver(() => { - if (stateRef.current.isFocused && target.disabled) { + if (stateRef.current.isFocused && target.disabled && document.contains(target)) { stateRef.current.observer.disconnect(); target.dispatchEvent(new FocusEvent('blur')); target.dispatchEvent(new FocusEvent('focusout', {bubbles: true})); @@ -123,5 +137,5 @@ export function useSyntheticBlurEvent(onBlur: (e: ReactFocusEv stateRef.current.observer.observe(target, {attributes: true, attributeFilter: ['disabled']}); } - }, []); + }, [onBlur]); } diff --git a/packages/@react-aria/utils/src/useUpdateEffect.ts b/packages/@react-aria/utils/src/useUpdateEffect.ts index f480311c22d..ce5378e07f3 100644 --- a/packages/@react-aria/utils/src/useUpdateEffect.ts +++ b/packages/@react-aria/utils/src/useUpdateEffect.ts @@ -24,4 +24,10 @@ export function useUpdateEffect(effect: EffectCallback, dependencies: any[]) { } // eslint-disable-next-line react-hooks/exhaustive-deps }, dependencies); + + // For strictmode, the above isn't sufficient for detecting 'initial mount', so we + // need to cleanup on unmount. + useEffect(() => () => { + isInitialMount.current = true; + }, []); } diff --git a/packages/@react-spectrum/calendar/test/Calendar.test.js b/packages/@react-spectrum/calendar/test/Calendar.test.js index 813c9991316..2b9dccd1e2b 100644 --- a/packages/@react-spectrum/calendar/test/Calendar.test.js +++ b/packages/@react-spectrum/calendar/test/Calendar.test.js @@ -10,6 +10,8 @@ * governing permissions and limitations under the License. */ +import {act} from '@testing-library/react'; + jest.mock('@react-aria/live-announcer'); import {announce} from '@react-aria/live-announcer'; import {Calendar} from '../'; @@ -21,12 +23,11 @@ import {useLocale} from '@react-aria/i18n'; let keyCodes = {'Enter': 13, ' ': 32, 'PageUp': 33, 'PageDown': 34, 'End': 35, 'Home': 36, 'ArrowLeft': 37, 'ArrowUp': 38, 'ArrowRight': 39, 'ArrowDown': 40}; describe('Calendar', () => { - beforeEach(() => { - jest.spyOn(window, 'requestAnimationFrame').mockImplementation(cb => cb()); + beforeAll(() => { + jest.useFakeTimers(); }); - afterEach(() => { - window.requestAnimationFrame.mockRestore(); + act(() => {jest.runAllTimers();}); }); describe('basics', () => { @@ -388,13 +389,13 @@ describe('Calendar', () => { }); }); - // These tests only work against v3 describe('announcing', () => { it('announces when the current month changes', () => { let {getAllByLabelText} = render(); let nextButton = getAllByLabelText('Next')[0]; triggerPress(nextButton); + act(() => {jest.runAllTimers();}); expect(announce).toHaveBeenCalledTimes(1); expect(announce).toHaveBeenCalledWith('July 2019'); @@ -405,6 +406,7 @@ describe('Calendar', () => { let newDate = getByText('17'); triggerPress(newDate); + act(() => {jest.runAllTimers();}); expect(announce).toHaveBeenCalledTimes(1); expect(announce).toHaveBeenCalledWith('Selected Date: Monday, June 17, 2019', 'polite', 4000); @@ -418,6 +420,8 @@ describe('Calendar', () => { expect(selectedDate).toHaveFocus(); fireEvent.keyDown(grid, {key: 'ArrowRight'}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowRight'}); + act(() => {jest.runAllTimers();}); expect(getByLabelText('Thursday, June 6, 2019', {exact: false})).toHaveFocus(); }); @@ -426,6 +430,7 @@ describe('Calendar', () => { let newDate = getByText('17'); triggerPress(newDate); + act(() => {jest.runAllTimers();}); expect(announce).toHaveBeenCalledTimes(1); expect(announce).toHaveBeenCalledWith('Selected Date: Saturday, February 17, 5 BC', 'polite', 4000); @@ -433,6 +438,7 @@ describe('Calendar', () => { announce.mockReset(); let nextButton = getAllByLabelText('Next')[0]; triggerPress(nextButton); + act(() => {jest.runAllTimers();}); expect(announce).toHaveBeenCalledTimes(1); expect(announce).toHaveBeenCalledWith('March 5 BC'); diff --git a/packages/@react-spectrum/calendar/test/CalendarBase.test.js b/packages/@react-spectrum/calendar/test/CalendarBase.test.js index 98384cb945b..d519fd941d9 100644 --- a/packages/@react-spectrum/calendar/test/CalendarBase.test.js +++ b/packages/@react-spectrum/calendar/test/CalendarBase.test.js @@ -24,8 +24,7 @@ let keyCodes = {'Enter': 13, ' ': 32, 'PageUp': 33, 'PageDown': 34, 'End': 35, ' describe('CalendarBase', () => { beforeAll(() => { - jest.useFakeTimers('legacy'); - jest.spyOn(window, 'requestAnimationFrame').mockImplementation(cb => cb()); + jest.useFakeTimers(); }); afterEach(() => { @@ -234,8 +233,7 @@ describe('CalendarBase', () => { it.each` Name | Calendar | props ${'v3 Calendar'} | ${Calendar} | ${{defaultValue: new CalendarDate(2019, 3, 10), minValue: new CalendarDate(2019, 2, 3), maxValue: new CalendarDate(2019, 4, 20)}} - ${'v3 RangeCalendar'} | ${RangeCalendar} | ${{defaultValue: {start: new CalendarDate(2019, 3, 10), end: new CalendarDate(2019, 3, 15)}, minValue: new CalendarDate(2019, 2, 3), maxValue: new CalendarDate(2019, 4, 20)}} - `('$Name should move focus when the previous or next buttons become disabled', ({Calendar, props}) => { + `('$Name should move focus when the previous or next buttons become disabled', async ({Calendar, props}) => { let {getByLabelText, getAllByLabelText} = render(); let prevButton = getByLabelText('Previous'); @@ -244,7 +242,7 @@ describe('CalendarBase', () => { expect(prevButton).not.toHaveAttribute('disabled'); expect(nextButton).not.toHaveAttribute('disabled'); - act(() => userEvent.click(prevButton)); + triggerPress(prevButton); expect(prevButton).toHaveAttribute('disabled'); expect(nextButton).not.toHaveAttribute('disabled'); expect(document.activeElement.getAttribute('aria-label').startsWith('Sunday, February 10, 2019')).toBe(true); diff --git a/packages/@react-spectrum/calendar/test/RangeCalendar.test.js b/packages/@react-spectrum/calendar/test/RangeCalendar.test.js index be3237f76ad..c84d22e2653 100644 --- a/packages/@react-spectrum/calendar/test/RangeCalendar.test.js +++ b/packages/@react-spectrum/calendar/test/RangeCalendar.test.js @@ -28,13 +28,11 @@ function type(key) { } describe('RangeCalendar', () => { - beforeEach(() => { - jest.useFakeTimers('legacy'); - jest.spyOn(window, 'requestAnimationFrame').mockImplementation(cb => cb()); + beforeAll(() => { + jest.useFakeTimers(); }); - afterEach(() => { - window.requestAnimationFrame.mockRestore(); + act(() => {jest.runAllTimers();}); }); describe('basics', () => { @@ -115,7 +113,6 @@ describe('RangeCalendar', () => { expect(grid).not.toHaveAttribute('aria-activedescendant'); }); - // v2 doesn't pass this test - it starts by showing the end date instead of the start date. it('should show selected dates across multiple months', () => { let {getByRole, getByLabelText, getAllByLabelText, getAllByRole} = render(); diff --git a/packages/@react-stately/calendar/src/useCalendarState.ts b/packages/@react-stately/calendar/src/useCalendarState.ts index fe6aab36958..8a50a891054 100644 --- a/packages/@react-stately/calendar/src/useCalendarState.ts +++ b/packages/@react-stately/calendar/src/useCalendarState.ts @@ -30,7 +30,7 @@ import { import {CalendarProps, DateValue} from '@react-types/calendar'; import {CalendarState} from './types'; import {useControlledState} from '@react-stately/utils'; -import {useMemo, useRef, useState} from 'react'; +import {useEffect, useMemo, useRef, useState} from 'react'; export interface CalendarStateOptions extends CalendarProps { /** The locale to display and edit the value according to. */ @@ -113,12 +113,15 @@ export function useCalendarState(props: Calenda // Reset focused date and visible range when calendar changes. let lastCalendarIdentifier = useRef(calendar.identifier); - if (calendar.identifier !== lastCalendarIdentifier.current) { - let newFocusedDate = toCalendar(focusedDate, calendar); - setStartDate(alignCenter(newFocusedDate, visibleDuration, locale, minValue, maxValue)); - setFocusedDate(newFocusedDate); - lastCalendarIdentifier.current = calendar.identifier; - } + let calIdentifier = calendar.identifier; + useEffect(() => { + if (calendar.identifier !== lastCalendarIdentifier.current) { + let newFocusedDate = toCalendar(focusedDate, calendar); + setStartDate(alignCenter(newFocusedDate, visibleDuration, locale, minValue, maxValue)); + setFocusedDate(newFocusedDate); + lastCalendarIdentifier.current = calIdentifier; + } + }, [calendar, focusedDate, visibleDuration, locale, minValue, maxValue, setFocusedDate, calIdentifier]); if (isInvalid(focusedDate, minValue, maxValue)) { // If the focused date was moved to an invalid value, it can't be focused, so constrain it. diff --git a/packages/@react-stately/calendar/src/useRangeCalendarState.ts b/packages/@react-stately/calendar/src/useRangeCalendarState.ts index 2bf7e8bb549..5303c2e4f31 100644 --- a/packages/@react-stately/calendar/src/useRangeCalendarState.ts +++ b/packages/@react-stately/calendar/src/useRangeCalendarState.ts @@ -18,7 +18,7 @@ import {RangeCalendarProps} from '@react-types/calendar'; import {RangeValue} from '@react-types/shared'; import {useCalendarState} from './useCalendarState'; import {useControlledState} from '@react-stately/utils'; -import {useMemo, useRef, useState} from 'react'; +import {useEffect, useMemo, useRef, useState} from 'react'; export interface RangeCalendarStateOptions extends RangeCalendarProps { /** The locale to display and edit the value according to. */ @@ -92,10 +92,12 @@ export function useRangeCalendarState(props: Ra // If the visible range changes, we need to update the available range. let lastVisibleRange = useRef(calendar.visibleRange); - if (!isEqualDay(calendar.visibleRange.start, lastVisibleRange.current.start) || !isEqualDay(calendar.visibleRange.end, lastVisibleRange.current.end)) { - updateAvailableRange(anchorDate); - lastVisibleRange.current = calendar.visibleRange; - } + useEffect(() => { + if (!isEqualDay(calendar.visibleRange.start, lastVisibleRange.current.start) || !isEqualDay(calendar.visibleRange.end, lastVisibleRange.current.end)) { + updateAvailableRange(anchorDate); + lastVisibleRange.current = calendar.visibleRange; + } + }); let setAnchorDate = (date: CalendarDate) => { if (date) { diff --git a/packages/@react-stately/datepicker/src/utils.ts b/packages/@react-stately/datepicker/src/utils.ts index b815dcd1830..850f3089dc9 100644 --- a/packages/@react-stately/datepicker/src/utils.ts +++ b/packages/@react-stately/datepicker/src/utils.ts @@ -12,7 +12,7 @@ import {Calendar, now, Time, toCalendar, toCalendarDate, toCalendarDateTime} from '@internationalized/date'; import {DatePickerProps, DateValue, Granularity, TimeValue} from '@react-types/datepicker'; -import {useRef} from 'react'; +import {useEffect, useRef} from 'react'; export function isInvalid(value: DateValue, minValue: DateValue, maxValue: DateValue) { return value != null && ( @@ -131,11 +131,15 @@ export function createPlaceholderDate(placeholderValue: DateValue, granularity: export function useDefaultProps(v: DateValue, granularity: Granularity): [Granularity, string] { // Compute default granularity and time zone from the value. If the value becomes null, keep the last values. let lastValue = useRef(v); - if (v) { - lastValue.current = v; + useEffect(() => { + if (v) { + lastValue.current = v; + } + }, [v]); + + if (!v) { + v = lastValue.current; } - - v = lastValue.current; let defaultTimeZone = (v && 'timeZone' in v ? v.timeZone : undefined); granularity = granularity || (v && 'minute' in v ? 'minute' : 'day');