diff --git a/packages/@react-spectrum/slider/test/RangeSlider.test.tsx b/packages/@react-spectrum/slider/test/RangeSlider.test.tsx
index 5ba48d67099..1c580e7e7a8 100644
--- a/packages/@react-spectrum/slider/test/RangeSlider.test.tsx
+++ b/packages/@react-spectrum/slider/test/RangeSlider.test.tsx
@@ -14,7 +14,7 @@ import {fireEvent, render} from '@react-spectrum/test-utils';
import {press, testKeypresses} from './utils';
import {Provider} from '@adobe/react-spectrum';
import {RangeSlider} from '../';
-import React, {useState} from 'react';
+import React, {useCallback, useState} from 'react';
import {theme} from '@react-spectrum/theme-default';
import userEvent from '@testing-library/user-event';
@@ -126,11 +126,14 @@ describe('RangeSlider', function () {
});
it('can be controlled', function () {
- let renders = [];
+ let setValues = [];
function Test() {
- let [value, setValue] = useState({start: 20, end: 40});
- renders.push(value);
+ let [value, _setValue] = useState({start: 20, end: 40});
+ let setValue = useCallback((val) => {
+ setValues.push(val);
+ _setValue(val);
+ }, [_setValue]);
return ();
}
@@ -154,7 +157,7 @@ describe('RangeSlider', function () {
expect(sliderRight).toHaveAttribute('aria-valuetext', '50');
expect(output).toHaveTextContent('30 – 50');
- expect(renders).toStrictEqual([{start: 20, end: 40}, {start: 30, end: 40}, {start: 30, end: 50}]);
+ expect(setValues).toStrictEqual([{start: 30, end: 40}, {start: 30, end: 50}]);
});
it('supports a custom valueLabel', function () {
diff --git a/packages/@react-spectrum/slider/test/Slider.test.tsx b/packages/@react-spectrum/slider/test/Slider.test.tsx
index c28974c8920..4acd318497e 100644
--- a/packages/@react-spectrum/slider/test/Slider.test.tsx
+++ b/packages/@react-spectrum/slider/test/Slider.test.tsx
@@ -13,7 +13,7 @@
import {act, fireEvent, installMouseEvent, render} from '@react-spectrum/test-utils';
import {press, testKeypresses} from './utils';
import {Provider} from '@adobe/react-spectrum';
-import React, {useState} from 'react';
+import React, {useCallback, useState} from 'react';
import {Slider} from '../';
import {theme} from '@react-spectrum/theme-default';
import userEvent from '@testing-library/user-event';
@@ -119,11 +119,14 @@ describe('Slider', function () {
});
it('can be controlled', function () {
- let renders = [];
+ let setValues = [];
function Test() {
- let [value, setValue] = useState(50);
- renders.push(value);
+ let [value, _setValue] = useState(50);
+ let setValue = useCallback((val) => {
+ setValues.push(val);
+ _setValue(val);
+ }, [_setValue]);
return ();
}
@@ -141,7 +144,7 @@ describe('Slider', function () {
expect(slider).toHaveAttribute('aria-valuetext', '55');
expect(output).toHaveTextContent('55');
- expect(renders).toStrictEqual([50, 55]);
+ expect(setValues).toStrictEqual([55]);
});
it('supports a custom getValueLabel', function () {
diff --git a/packages/@react-spectrum/table/src/TableView.tsx b/packages/@react-spectrum/table/src/TableView.tsx
index 1e5e49dbd57..1380d372acf 100644
--- a/packages/@react-spectrum/table/src/TableView.tsx
+++ b/packages/@react-spectrum/table/src/TableView.tsx
@@ -12,7 +12,7 @@
import {AriaLabelingProps, DOMProps, DOMRef, DropTarget, FocusableElement, FocusableRef, SpectrumSelectionProps, StyleProps} from '@react-types/shared';
import ArrowDownSmall from '@spectrum-icons/ui/ArrowDownSmall';
-import {chain, mergeProps, scrollIntoView, scrollIntoViewport, useLayoutEffect} from '@react-aria/utils';
+import {chain, mergeProps, scrollIntoView, scrollIntoViewport} from '@react-aria/utils';
import {Checkbox} from '@react-spectrum/checkbox';
import ChevronDownMedium from '@spectrum-icons/ui/ChevronDownMedium';
import {
@@ -39,8 +39,8 @@ import ListGripper from '@spectrum-icons/ui/ListGripper';
import {Nubbin} from './Nubbin';
import {ProgressCircle} from '@react-spectrum/progress';
import React, {DOMAttributes, HTMLAttributes, Key, ReactElement, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react';
-import {Rect, ReusableView, useVirtualizerState} from '@react-stately/virtualizer';
import {Resizer} from './Resizer';
+import {ReusableView, useVirtualizerState} from '@react-stately/virtualizer';
import {RootDropIndicator} from './RootDropIndicator';
import {DragPreview as SpectrumDragPreview} from './DragPreview';
import styles from '@adobe/spectrum-css-temp/components/table/vars.css';
@@ -387,8 +387,9 @@ function TableView(props: SpectrumTableProps, ref: DOMRef(props: SpectrumTableProps, ref: DOMRef
+ }>
+ {reusableView.rendered}
+
);
};
@@ -578,7 +581,8 @@ function TableView(props: SpectrumTableProps, ref: DOMRef ({
tabIndex: otherProps.tabIndex,
focusedKey,
- scrollToItem
- }, state, domRef);
+ scrollToItem,
+ isLoading,
+ onLoadMore
+ }), [otherProps.tabIndex, focusedKey, scrollToItem, isLoading, onLoadMore]);
+
+ let {virtualizerProps, scrollViewProps: {onVisibleRectChange}} = useVirtualizer(memoedVirtualizerProps, state, domRef);
// this effect runs whenever the contentSize changes, it doesn't matter what the content size is
// only that it changes in a resize, and when that happens, we want to sync the body to the
@@ -654,26 +657,6 @@ function TableVirtualizer({layout, collection, focusedKey, renderView, renderWra
headerRef.current.scrollLeft = bodyRef.current.scrollLeft;
}, [bodyRef, headerRef]);
- let onVisibleRectChange = useCallback((rect: Rect) => {
- state.setVisibleRect(rect);
-
- if (!isLoading && onLoadMore) {
- let scrollOffset = state.virtualizer.contentSize.height - rect.height * 2;
- if (rect.y > scrollOffset) {
- onLoadMore();
- }
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [onLoadMore, isLoading, state.setVisibleRect, state.virtualizer]);
-
- useLayoutEffect(() => {
- if (!isLoading && onLoadMore && !state.isAnimating) {
- if (state.contentSize.height <= state.virtualizer.visibleRect.height) {
- onLoadMore();
- }
- }
- }, [state.contentSize, state.virtualizer, state.isAnimating, onLoadMore, isLoading]);
-
let resizerPosition = layout.getResizerPosition() - 2;
let resizerAtEdge = resizerPosition > Math.max(state.virtualizer.contentSize.width, state.virtualizer.visibleRect.width) - 3;
@@ -1119,7 +1102,7 @@ function TableDragHeaderCell({column}) {
{jest.runAllTimers();});
- expect(onLoadMore).toHaveBeenCalledTimes(3);
+ expect(onLoadMore).toHaveBeenCalledTimes(1);
});
it('should automatically fire onLoadMore if there aren\'t enough items to fill the Table', function () {
@@ -4077,8 +4077,7 @@ describe('TableView', function () {
render();
act(() => jest.runAllTimers());
- // first loadMore triggered by onVisibleRectChange, other 2 by useLayoutEffect
- expect(onLoadMoreSpy).toHaveBeenCalledTimes(3);
+ expect(onLoadMoreSpy).toHaveBeenCalledTimes(1);
});
});
diff --git a/packages/@react-spectrum/tag/src/TagGroup.tsx b/packages/@react-spectrum/tag/src/TagGroup.tsx
index b4be32d9547..b7739c8249c 100644
--- a/packages/@react-spectrum/tag/src/TagGroup.tsx
+++ b/packages/@react-spectrum/tag/src/TagGroup.tsx
@@ -94,7 +94,7 @@ function TagGroup(props: SpectrumTagGroupProps, ref: DOMRef
let currContainerRef: HTMLDivElement | null = containerRef.current;
let currTagsRef: HTMLDivElement | null = tagsRef.current;
let currActionsRef: HTMLDivElement | null = actionsRef.current;
- if (!currContainerRef || !currTagsRef || state.collection.size === 0) {
+ if (!currContainerRef || !currTagsRef || !currActionsRef || state.collection.size === 0) {
return {
visibleTagCount: 0,
showCollapseButton: false
diff --git a/packages/@react-spectrum/toast/src/ToastContainer.tsx b/packages/@react-spectrum/toast/src/ToastContainer.tsx
index 4f4c348e5ac..97d2596ffb5 100644
--- a/packages/@react-spectrum/toast/src/ToastContainer.tsx
+++ b/packages/@react-spectrum/toast/src/ToastContainer.tsx
@@ -55,6 +55,12 @@ function subscribe(fn: () => void) {
return () => subscriptions.delete(fn);
}
+function triggerSubscriptions() {
+ for (let fn of subscriptions) {
+ fn();
+ }
+}
+
function getActiveToastContainer() {
return toastProviders.values().next().value;
}
@@ -73,10 +79,12 @@ export function ToastContainer(props: SpectrumToastContainerProps): ReactElement
// We use a ref to do this, since it will have a stable identity
// over the lifetime of the component.
let ref = useRef();
- toastProviders.add(ref);
// eslint-disable-next-line arrow-body-style
useEffect(() => {
+ toastProviders.add(ref);
+ triggerSubscriptions();
+
return () => {
// When this toast provider unmounts, reset all animations so that
// when the new toast provider renders, it is seamless.
@@ -88,9 +96,7 @@ export function ToastContainer(props: SpectrumToastContainerProps): ReactElement
// This will cause all other instances to re-render,
// and the first one to become the new active toast provider.
toastProviders.delete(ref);
- for (let fn of subscriptions) {
- fn();
- }
+ triggerSubscriptions();
};
}, []);
diff --git a/packages/@react-spectrum/toast/test/ToastContainer.test.js b/packages/@react-spectrum/toast/test/ToastContainer.test.js
index dd26498cba8..8a8fc728e98 100644
--- a/packages/@react-spectrum/toast/test/ToastContainer.test.js
+++ b/packages/@react-spectrum/toast/test/ToastContainer.test.js
@@ -326,7 +326,14 @@ describe('Toast Provider and Container', function () {
return (
diff --git a/packages/@react-spectrum/utils/test/Slots.test.js b/packages/@react-spectrum/utils/test/Slots.test.js
index afee605924a..7ced4ba4732 100644
--- a/packages/@react-spectrum/utils/test/Slots.test.js
+++ b/packages/@react-spectrum/utils/test/Slots.test.js
@@ -96,7 +96,16 @@ describe('Slots', function () {
expect(onPressUser).toHaveBeenCalledTimes(1);
});
- it('lets users set their own id', function () {
+ it.skip('lets users set their own id', function () {
+ // This test does not work in strict mode because useId merges ids.
+ // Only the id that goes through useId can be updated (in this case "bar"),
+ // so the non-useId generated id ends up winning. Outside strict mode, this
+ // appears to work because the id is not registered until after the mergeProps
+ // so it is not updated. In strict mode we render twice so the id is updated
+ // during the second render. This would also be broken outside strict mode if
+ // the component _ever_ re-rendered, however. In the real world, all of the ids
+ // we would pass through slots are generated by useId (tested below) so this
+ // isn't a big problem.
let slots = {
slotname: {id: 'foo'}
};
diff --git a/packages/@react-stately/calendar/src/useCalendarState.ts b/packages/@react-stately/calendar/src/useCalendarState.ts
index fe6aab36958..f22310898ae 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 {useMemo, useState} from 'react';
export interface CalendarStateOptions extends CalendarProps {
/** The locale to display and edit the value according to. */
@@ -112,12 +112,12 @@ export function useCalendarState(props: Calenda
}, [startDate, visibleDuration]);
// Reset focused date and visible range when calendar changes.
- let lastCalendarIdentifier = useRef(calendar.identifier);
- if (calendar.identifier !== lastCalendarIdentifier.current) {
+ let [lastCalendarIdentifier, setLastCalendarIdentifier] = useState(calendar.identifier);
+ if (calendar.identifier !== lastCalendarIdentifier) {
let newFocusedDate = toCalendar(focusedDate, calendar);
setStartDate(alignCenter(newFocusedDate, visibleDuration, locale, minValue, maxValue));
setFocusedDate(newFocusedDate);
- lastCalendarIdentifier.current = calendar.identifier;
+ setLastCalendarIdentifier(calendar.identifier);
}
if (isInvalid(focusedDate, minValue, maxValue)) {
diff --git a/packages/@react-stately/calendar/src/useRangeCalendarState.ts b/packages/@react-stately/calendar/src/useRangeCalendarState.ts
index 2bf7e8bb549..bf586f867ca 100644
--- a/packages/@react-stately/calendar/src/useRangeCalendarState.ts
+++ b/packages/@react-stately/calendar/src/useRangeCalendarState.ts
@@ -91,10 +91,10 @@ 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)) {
+ let [lastVisibleRange, setLastVisibleRange] = useState(calendar.visibleRange);
+ if (!isEqualDay(calendar.visibleRange.start, lastVisibleRange.start) || !isEqualDay(calendar.visibleRange.end, lastVisibleRange.end)) {
updateAvailableRange(anchorDate);
- lastVisibleRange.current = calendar.visibleRange;
+ setLastVisibleRange(calendar.visibleRange);
}
let setAnchorDate = (date: CalendarDate) => {
diff --git a/packages/@react-stately/color/src/useColorAreaState.ts b/packages/@react-stately/color/src/useColorAreaState.ts
index fe704376f54..b402983f85b 100644
--- a/packages/@react-stately/color/src/useColorAreaState.ts
+++ b/packages/@react-stately/color/src/useColorAreaState.ts
@@ -85,13 +85,16 @@ export function useColorAreaState(props: ColorAreaProps): ColorAreaState {
defaultValue = DEFAULT_COLOR;
}
- let [color, setColor] = useControlledState(value && normalizeColor(value), defaultValue && normalizeColor(defaultValue), onChange);
+ let [color, setColorState] = useControlledState(value && normalizeColor(value), defaultValue && normalizeColor(defaultValue), onChange);
let valueRef = useRef(color);
- valueRef.current = color;
+ let setColor = (color: Color) => {
+ valueRef.current = color;
+ setColorState(color);
+ };
let channels = useMemo(() =>
- valueRef.current.getColorSpaceAxes({xChannel, yChannel}),
- [xChannel, yChannel]
+ color.getColorSpaceAxes({xChannel, yChannel}),
+ [color, xChannel, yChannel]
);
let xChannelRange = color.getChannelRange(channels.xChannel);
@@ -100,7 +103,7 @@ export function useColorAreaState(props: ColorAreaProps): ColorAreaState {
let {minValue: minValueY, maxValue: maxValueY, step: stepY, pageSize: pageSizeY} = yChannelRange;
let [isDragging, setDragging] = useState(false);
- let isDraggingRef = useRef(false).current;
+ let isDraggingRef = useRef(false);
let xValue = color.getChannelValue(channels.xChannel);
let yValue = color.getChannelValue(channels.yChannel);
@@ -108,15 +111,15 @@ export function useColorAreaState(props: ColorAreaProps): ColorAreaState {
if (v === xValue) {
return;
}
- valueRef.current = color.withChannelValue(channels.xChannel, v);
- setColor(valueRef.current);
+ let newColor = color.withChannelValue(channels.xChannel, v);
+ setColor(newColor);
};
let setYValue = (v: number) => {
if (v === yValue) {
return;
}
- valueRef.current = color.withChannelValue(channels.yChannel, v);
- setColor(valueRef.current);
+ let newColor = color.withChannelValue(channels.yChannel, v);
+ setColor(newColor);
};
return {
@@ -127,9 +130,7 @@ export function useColorAreaState(props: ColorAreaProps): ColorAreaState {
yChannelPageStep: pageSizeY,
value: color,
setValue(value) {
- let c = normalizeColor(value);
- valueRef.current = c;
- setColor(c);
+ setColor(normalizeColor(value));
},
xValue,
setXValue,
@@ -171,8 +172,8 @@ export function useColorAreaState(props: ColorAreaProps): ColorAreaState {
setYValue(snapValueToStep(yValue - stepSize, minValueY, maxValueY, stepY));
},
setDragging(isDragging) {
- let wasDragging = isDraggingRef;
- isDraggingRef = isDragging;
+ let wasDragging = isDraggingRef.current;
+ isDraggingRef.current = isDragging;
if (onChangeEnd && !isDragging && wasDragging) {
onChangeEnd(valueRef.current);
diff --git a/packages/@react-stately/color/src/useColorFieldState.ts b/packages/@react-stately/color/src/useColorFieldState.ts
index e02ce7f1ec7..7aea1520d13 100644
--- a/packages/@react-stately/color/src/useColorFieldState.ts
+++ b/packages/@react-stately/color/src/useColorFieldState.ts
@@ -14,7 +14,7 @@ import {Color, ColorFieldProps} from '@react-types/color';
import {parseColor} from './Color';
import {useColor} from './useColor';
import {useControlledState} from '@react-stately/utils';
-import {useMemo, useRef, useState} from 'react';
+import {useMemo, useState} from 'react';
export interface ColorFieldState {
/**
@@ -85,13 +85,12 @@ export function useColorFieldState(
}
};
- let prevValue = useRef(colorValue);
- if (prevValue.current !== colorValue) {
+ let [prevValue, setPrevValue] = useState(colorValue);
+ if (prevValue !== colorValue) {
setInputValue(colorValue ? colorValue.toString('hex') : '');
- prevValue.current = colorValue;
+ setPrevValue(colorValue);
}
-
let parsedValue = useMemo(() => {
let color;
try {
@@ -101,8 +100,6 @@ export function useColorFieldState(
}
return color;
}, [inputValue]);
- let parsed = useRef(null);
- parsed.current = parsedValue;
let commit = () => {
// Set to empty state if input value is empty
@@ -113,12 +110,12 @@ export function useColorFieldState(
}
// if it failed to parse, then reset input to formatted version of current number
- if (parsed.current == null) {
+ if (parsedValue == null) {
setInputValue(colorValue ? colorValue.toString('hex') : '');
return;
}
- safelySetColorValue(parsed.current);
+ safelySetColorValue(parsedValue);
// in a controlled state, the numberValue won't change, so we won't go back to our old input without help
let newColorValue = '';
if (colorValue) {
@@ -128,7 +125,7 @@ export function useColorFieldState(
};
let increment = () => {
- let newValue = addColorValue(parsed.current, step);
+ let newValue = addColorValue(parsedValue, step);
// if we've arrived at the same value that was previously in the state, the
// input value should be updated to match
// ex type 4, press increment, highlight the number in the input, type 4 again, press increment
@@ -139,7 +136,7 @@ export function useColorFieldState(
safelySetColorValue(newValue);
};
let decrement = () => {
- let newValue = addColorValue(parsed.current, -step);
+ let newValue = addColorValue(parsedValue, -step);
// if we've arrived at the same value that was previously in the state, the
// input value should be updated to match
// ex type 4, press increment, highlight the number in the input, type 4 again, press increment
diff --git a/packages/@react-stately/color/src/useColorWheelState.ts b/packages/@react-stately/color/src/useColorWheelState.ts
index 566c8a1117f..23036fe8a38 100644
--- a/packages/@react-stately/color/src/useColorWheelState.ts
+++ b/packages/@react-stately/color/src/useColorWheelState.ts
@@ -99,14 +99,17 @@ export function useColorWheelState(props: ColorWheelProps): ColorWheelState {
defaultValue = DEFAULT_COLOR;
}
- let [value, setValue] = useControlledState(normalizeColor(props.value), normalizeColor(defaultValue), onChange);
+ let [value, setValueState] = useControlledState(normalizeColor(props.value), normalizeColor(defaultValue), onChange);
let valueRef = useRef(value);
- valueRef.current = value;
+ let setValue = (value: Color) => {
+ valueRef.current = value;
+ setValueState(value);
+ };
let channelRange = value.getChannelRange('hue');
let {minValue: minValueX, maxValue: maxValueX, step: step, pageSize: pageStep} = channelRange;
let [isDragging, setDragging] = useState(false);
- let isDraggingRef = useRef(false).current;
+ let isDraggingRef = useRef(false);
let hue = value.getChannelValue('hue');
function setHue(v: number) {
@@ -117,7 +120,6 @@ export function useColorWheelState(props: ColorWheelProps): ColorWheelState {
v = roundToStep(mod(v, 360), step);
if (hue !== v) {
let color = value.withChannelValue('hue', v);
- valueRef.current = color;
setValue(color);
}
}
@@ -128,7 +130,6 @@ export function useColorWheelState(props: ColorWheelProps): ColorWheelState {
pageStep,
setValue(v) {
let color = normalizeColor(v);
- valueRef.current = color;
setValue(color);
},
hue,
@@ -159,8 +160,8 @@ export function useColorWheelState(props: ColorWheelProps): ColorWheelState {
}
},
setDragging(isDragging) {
- let wasDragging = isDraggingRef;
- isDraggingRef = isDragging;
+ let wasDragging = isDraggingRef.current;
+ isDraggingRef.current = isDragging;
if (onChangeEnd && !isDragging && wasDragging) {
onChangeEnd(valueRef.current);
diff --git a/packages/@react-stately/data/src/useAsyncList.ts b/packages/@react-stately/data/src/useAsyncList.ts
index bd18c1a5589..9cc3bf253ec 100644
--- a/packages/@react-stately/data/src/useAsyncList.ts
+++ b/packages/@react-stately/data/src/useAsyncList.ts
@@ -11,7 +11,7 @@
*/
import {createListActions, ListData, ListState} from './useListData';
-import {Key, Reducer, useEffect, useReducer} from 'react';
+import {Key, Reducer, useEffect, useReducer, useRef} from 'react';
import {LoadingState, Selection, SortDescriptor} from '@react-types/shared';
export interface AsyncListOptions {
@@ -313,8 +313,12 @@ export function useAsyncList(options: AsyncListOptions): As
}
};
+ let didDispatchInitialFetch = useRef(false);
useEffect(() => {
- dispatchFetch({type: 'loading'}, load);
+ if (!didDispatchInitialFetch.current) {
+ dispatchFetch({type: 'loading'}, load);
+ didDispatchInitialFetch.current = true;
+ }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
diff --git a/packages/@react-stately/datepicker/src/utils.ts b/packages/@react-stately/datepicker/src/utils.ts
index b815dcd1830..0cea2c7d670 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 {useState} from 'react';
export function isInvalid(value: DateValue, minValue: DateValue, maxValue: DateValue) {
return value != null && (
@@ -130,12 +130,11 @@ 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;
+ let [lastValue, setLastValue] = useState<[Granularity, string] | null>(null);
+ if (!v && lastValue) {
+ return lastValue;
}
- v = lastValue.current;
let defaultTimeZone = (v && 'timeZone' in v ? v.timeZone : undefined);
granularity = granularity || (v && 'minute' in v ? 'minute' : 'day');
@@ -144,5 +143,10 @@ export function useDefaultProps(v: DateValue, granularity: Granularity): [Granul
throw new Error('Invalid granularity ' + granularity + ' for value ' + v.toString());
}
+ // If the granularity or time zone changed, update the last value.
+ if (!lastValue || lastValue[0] !== granularity || lastValue[1] !== defaultTimeZone) {
+ setLastValue([granularity, defaultTimeZone]);
+ }
+
return [granularity, defaultTimeZone];
}
diff --git a/packages/@react-stately/grid/src/useGridState.ts b/packages/@react-stately/grid/src/useGridState.ts
index 19d801fd572..6686659fe7f 100644
--- a/packages/@react-stately/grid/src/useGridState.ts
+++ b/packages/@react-stately/grid/src/useGridState.ts
@@ -73,7 +73,7 @@ export function useGridState>(prop
rows.length - 1);
let newRow:GridNode;
while (index >= 0) {
- if (!selectionManager.isDisabled(rows[index].key)) {
+ if (!selectionManager.isDisabled(rows[index].key) && rows[index].type !== 'headerrow') {
newRow = rows[index];
break;
}
diff --git a/packages/@react-stately/numberfield/src/useNumberFieldState.ts b/packages/@react-stately/numberfield/src/useNumberFieldState.ts
index c75ba28610d..6dd6cc2cb16 100644
--- a/packages/@react-stately/numberfield/src/useNumberFieldState.ts
+++ b/packages/@react-stately/numberfield/src/useNumberFieldState.ts
@@ -13,7 +13,7 @@
import {clamp, snapValueToStep, useControlledState} from '@react-stately/utils';
import {NumberFieldProps} from '@react-types/numberfield';
import {NumberFormatter, NumberParser} from '@internationalized/number';
-import {useCallback, useMemo, useRef, useState} from 'react';
+import {useCallback, useMemo, useState} from 'react';
export interface NumberFieldState {
/**
@@ -104,21 +104,17 @@ export function useNumberFieldState(
// Update the input value when the number value or format options change. This is done
// in a useEffect so that the controlled behavior is correct and we only update the
// textfield after prop changes.
- let prevValue = useRef(numberValue);
- let prevLocale = useRef(locale);
- let prevFormatOptions = useRef(formatOptions);
- if (!Object.is(numberValue, prevValue.current) || locale !== prevLocale.current || formatOptions !== prevFormatOptions.current) {
+ let [prevValue, setPrevValue] = useState(numberValue);
+ let [prevLocale, setPrevLocale] = useState(locale);
+ let [prevFormatOptions, setPrevFormatOptions] = useState(formatOptions);
+ if (!Object.is(numberValue, prevValue) || locale !== prevLocale || formatOptions !== prevFormatOptions) {
setInputValue(format(numberValue));
- prevValue.current = numberValue;
- prevLocale.current = locale;
- prevFormatOptions.current = formatOptions;
+ setPrevValue(numberValue);
+ setPrevLocale(locale);
+ setPrevFormatOptions(formatOptions);
}
- // Store last parsed value in a ref so it can be used by increment/decrement below
let parsedValue = useMemo(() => numberParser.parse(inputValue), [numberParser, inputValue]);
- let parsed = useRef(0);
- parsed.current = parsedValue;
-
let commit = () => {
// Set to empty state if input value is empty
if (!inputValue.length) {
@@ -128,7 +124,7 @@ export function useNumberFieldState(
}
// if it failed to parse, then reset input to formatted version of current number
- if (isNaN(parsed.current)) {
+ if (isNaN(parsedValue)) {
setInputValue(format(numberValue));
return;
}
@@ -136,9 +132,9 @@ export function useNumberFieldState(
// Clamp to min and max, round to the nearest step, and round to specified number of digits
let clampedValue: number;
if (isNaN(step)) {
- clampedValue = clamp(parsed.current, minValue, maxValue);
+ clampedValue = clamp(parsedValue, minValue, maxValue);
} else {
- clampedValue = snapValueToStep(parsed.current, minValue, maxValue, step);
+ clampedValue = snapValueToStep(parsedValue, minValue, maxValue, step);
}
clampedValue = numberParser.parse(format(clampedValue));
@@ -149,7 +145,7 @@ export function useNumberFieldState(
};
let safeNextStep = (operation: '+' | '-', minMax: number) => {
- let prev = parsed.current;
+ let prev = parsedValue;
if (isNaN(prev)) {
// if the input is empty, start from the min/max value when incrementing/decrementing,
diff --git a/packages/@react-stately/slider/src/useSliderState.ts b/packages/@react-stately/slider/src/useSliderState.ts
index 2132000bd45..801759db3cc 100644
--- a/packages/@react-stately/slider/src/useSliderState.ts
+++ b/packages/@react-stately/slider/src/useSliderState.ts
@@ -182,19 +182,26 @@ export function useSliderState(props: SliderStateOp
let onChange = createOnChange(props.value, props.defaultValue, props.onChange);
let onChangeEnd = createOnChange(props.value, props.defaultValue, props.onChangeEnd);
- const [values, setValues] = useControlledState(
+ const [values, setValuesState] = useControlledState(
value,
defaultValue,
onChange
);
- const [isDraggings, setDraggings] = useState(new Array(values.length).fill(false));
+ const [isDraggings, setDraggingsState] = useState(new Array(values.length).fill(false));
const isEditablesRef = useRef(new Array(values.length).fill(true));
const [focusedIndex, setFocusedIndex] = useState(undefined);
- const valuesRef = useRef(null);
- valuesRef.current = values;
- const isDraggingsRef = useRef(null);
- isDraggingsRef.current = isDraggings;
+ const valuesRef = useRef(values);
+ const isDraggingsRef = useRef(isDraggings);
+ let setValues = (values: number[]) => {
+ valuesRef.current = values;
+ setValuesState(values);
+ };
+
+ let setDraggings = (draggings: boolean[]) => {
+ isDraggingsRef.current = draggings;
+ setDraggingsState(draggings);
+ };
function getValuePercent(value: number) {
return (value - minValue) / (maxValue - minValue);
@@ -224,8 +231,8 @@ export function useSliderState(props: SliderStateOp
// Round value to multiple of step, clamp value between min and max
value = snapValueToStep(value, thisMin, thisMax, step);
- valuesRef.current = replaceIndex(valuesRef.current, index, value);
- setValues(valuesRef.current);
+ let newValues = replaceIndex(values, index, value);
+ setValues(newValues);
}
function updateDragging(index: number, dragging: boolean) {
diff --git a/packages/@react-stately/table/src/TableHeader.ts b/packages/@react-stately/table/src/TableHeader.ts
index b56befa7523..bff2d613f04 100644
--- a/packages/@react-stately/table/src/TableHeader.ts
+++ b/packages/@react-stately/table/src/TableHeader.ts
@@ -10,6 +10,7 @@
* governing permissions and limitations under the License.
*/
+import {CollectionBuilderContext} from './useTableState';
import {PartialNode} from '@react-stately/collections';
import React, {ReactElement} from 'react';
import {TableHeaderProps} from '@react-types/table';
@@ -18,8 +19,12 @@ function TableHeader(props: TableHeaderProps): ReactElement { // eslint-di
return null;
}
-TableHeader.getCollectionNode = function* getCollectionNode(props: TableHeaderProps): Generator, void, any> {
+TableHeader.getCollectionNode = function* getCollectionNode(props: TableHeaderProps, context: CollectionBuilderContext): Generator, void, any> {
let {children, columns} = props;
+
+ // Clear columns so they aren't double added in strict mode.
+ context.columns = [];
+
if (typeof children === 'function') {
if (!columns) {
throw new Error('props.children was a function but props.columns is missing');
diff --git a/packages/@react-stately/tabs/src/useTabListState.ts b/packages/@react-stately/tabs/src/useTabListState.ts
index 8d8c6eca49b..281a1ac59b5 100644
--- a/packages/@react-stately/tabs/src/useTabListState.ts
+++ b/packages/@react-stately/tabs/src/useTabListState.ts
@@ -13,7 +13,7 @@
import {CollectionStateBase} from '@react-types/shared';
import {SingleSelectListState, useSingleSelectListState} from '@react-stately/list';
import {TabListProps} from '@react-types/tabs';
-import {useRef} from 'react';
+import {useEffect, useRef} from 'react';
export interface TabListStateOptions extends Omit, 'children'>, CollectionStateBase {}
@@ -39,30 +39,32 @@ export function useTabListState(props: TabListStateOptions)
} = state;
let lastSelectedKey = useRef(currentSelectedKey);
- // Ensure a tab is always selected (in case no selected key was specified or if selected item was deleted from collection)
- let selectedKey = currentSelectedKey;
- if (selectionManager.isEmpty || !collection.getItem(selectedKey)) {
- selectedKey = collection.getFirstKey();
- // loop over tabs until we find one that isn't disabled and select that
- while (state.disabledKeys.has(selectedKey) && selectedKey !== collection.getLastKey()) {
- selectedKey = collection.getKeyAfter(selectedKey);
- }
- // if this check is true, then every item is disabled, it makes more sense to default to the first key than the last
- if (state.disabledKeys.has(selectedKey) && selectedKey === collection.getLastKey()) {
+ useEffect(() => {
+ // Ensure a tab is always selected (in case no selected key was specified or if selected item was deleted from collection)
+ let selectedKey = currentSelectedKey;
+ if (selectionManager.isEmpty || !collection.getItem(selectedKey)) {
selectedKey = collection.getFirstKey();
- }
+ // loop over tabs until we find one that isn't disabled and select that
+ while (state.disabledKeys.has(selectedKey) && selectedKey !== collection.getLastKey()) {
+ selectedKey = collection.getKeyAfter(selectedKey);
+ }
+ // if this check is true, then every item is disabled, it makes more sense to default to the first key than the last
+ if (state.disabledKeys.has(selectedKey) && selectedKey === collection.getLastKey()) {
+ selectedKey = collection.getFirstKey();
+ }
- if (selectedKey != null) {
- // directly set selection because replace/toggle selection won't consider disabled keys
- selectionManager.setSelectedKeys([selectedKey]);
+ if (selectedKey != null) {
+ // directly set selection because replace/toggle selection won't consider disabled keys
+ selectionManager.setSelectedKeys([selectedKey]);
+ }
}
- }
- // If the tablist doesn't have focus and the selected key changes or if there isn't a focused key yet, change focused key to the selected key if it exists.
- if (selectedKey != null && selectionManager.focusedKey == null || (!selectionManager.isFocused && selectedKey !== lastSelectedKey.current)) {
- selectionManager.setFocusedKey(selectedKey);
- }
- lastSelectedKey.current = selectedKey;
+ // If the tablist doesn't have focus and the selected key changes or if there isn't a focused key yet, change focused key to the selected key if it exists.
+ if (selectedKey != null && selectionManager.focusedKey == null || (!selectionManager.isFocused && selectedKey !== lastSelectedKey.current)) {
+ selectionManager.setFocusedKey(selectedKey);
+ }
+ lastSelectedKey.current = selectedKey;
+ });
return {
...state,
diff --git a/packages/@react-stately/utils/src/useControlledState.ts b/packages/@react-stately/utils/src/useControlledState.ts
index 2e394844b0a..e0925fa85be 100644
--- a/packages/@react-stately/utils/src/useControlledState.ts
+++ b/packages/@react-stately/utils/src/useControlledState.ts
@@ -10,7 +10,7 @@
* governing permissions and limitations under the License.
*/
-import {useCallback, useRef, useState} from 'react';
+import {useCallback, useEffect, useRef, useState} from 'react';
export function useControlledState(
value: T,
@@ -18,26 +18,32 @@ export function useControlledState(
onChange: (value: T, ...args: any[]) => void
): [T, (value: T, ...args: any[]) => void] {
let [stateValue, setStateValue] = useState(value || defaultValue);
- let ref = useRef(value !== undefined);
- let wasControlled = ref.current;
- let isControlled = value !== undefined;
- // Internal state reference for useCallback
- let stateRef = useRef(stateValue);
- if (wasControlled !== isControlled) {
- console.warn(`WARN: A component changed from ${wasControlled ? 'controlled' : 'uncontrolled'} to ${isControlled ? 'controlled' : 'uncontrolled'}.`);
- }
- ref.current = isControlled;
+ let isControlledRef = useRef(value !== undefined);
+ let isControlled = value !== undefined;
+ useEffect(() => {
+ let wasControlled = isControlledRef.current;
+ if (wasControlled !== isControlled) {
+ console.warn(`WARN: A component changed from ${wasControlled ? 'controlled' : 'uncontrolled'} to ${isControlled ? 'controlled' : 'uncontrolled'}.`);
+ }
+ isControlledRef.current = isControlled;
+ }, [isControlled]);
+ let currentValue = isControlled ? value : stateValue;
let setValue = useCallback((value, ...args) => {
let onChangeCaller = (value, ...onChangeArgs) => {
if (onChange) {
- if (!Object.is(stateRef.current, value)) {
+ if (!Object.is(currentValue, value)) {
onChange(value, ...onChangeArgs);
}
}
if (!isControlled) {
- stateRef.current = value;
+ // If uncontrolled, mutate the currentValue local variable so that
+ // calling setState multiple times with the same value only emits onChange once.
+ // We do not use a ref for this because we specifically _do_ want the value to
+ // reset every render, and assigning to a ref in render breaks aborted suspended renders.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ currentValue = value;
}
};
@@ -49,7 +55,7 @@ export function useControlledState(
// if we're in an uncontrolled state, then we also return the value of myFunc which to setState looks as though it was just called with myFunc from the beginning
// otherwise we just return the controlled value, which won't cause a rerender because React knows to bail out when the value is the same
let updateFunction = (oldValue, ...functionArgs) => {
- let interceptedValue = value(isControlled ? stateRef.current : oldValue, ...functionArgs);
+ let interceptedValue = value(isControlled ? currentValue : oldValue, ...functionArgs);
onChangeCaller(interceptedValue, ...args);
if (!isControlled) {
return interceptedValue;
@@ -63,14 +69,7 @@ export function useControlledState(
}
onChangeCaller(value, ...args);
}
- }, [isControlled, onChange]);
-
- // If a controlled component's value prop changes, we need to update stateRef
- if (isControlled) {
- stateRef.current = value;
- } else {
- value = stateValue;
- }
+ }, [isControlled, currentValue, onChange]);
- return [value, setValue];
+ return [currentValue, setValue];
}
diff --git a/packages/@react-stately/utils/test/useControlledState.test.js b/packages/@react-stately/utils/test/useControlledState.test.js
index 1a6875c0528..cdf02763d5b 100644
--- a/packages/@react-stately/utils/test/useControlledState.test.js
+++ b/packages/@react-stately/utils/test/useControlledState.test.js
@@ -88,7 +88,7 @@ describe('useControlledState tests', function () {
let TestComponent = (props) => {
let [state, setState] = useControlledState(props.value, props.defaultValue, props.onChange);
useEffect(() => renderSpy(), [state]);
- return