From a89803a06c1a1099141952f45c10f1e91019c04f Mon Sep 17 00:00:00 2001 From: Slava Dodonov Date: Sun, 5 Jul 2026 02:56:20 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20Enable=20react-hooks=20l?= =?UTF-8?q?int=20rules=20and=20fix=20all=20stale-closure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/react-hooks-lint.md | 16 +++++++++ eslint.config.mjs | 9 +++++ packages/hooks/src/index.ts | 1 + packages/hooks/src/useEventCallback.ts | 32 +++++++++++++++++ packages/hooks/src/useMutationObserver.ts | 3 ++ packages/hooks/src/useScrollSnap.ts | 34 +++++++++++-------- packages/hooks/src/useSelection.ts | 17 +++++----- packages/react-context/src/createContext.tsx | 3 ++ packages/ui/src/Calendar/Calendar.tsx | 12 +++---- .../__tests__/Calendar.browser.test.tsx | 18 ++++++++++ packages/ui/src/Collapsible/Collapsible.tsx | 8 +++-- packages/ui/src/DataGrid/DataGrid.tsx | 6 ++-- .../components/DataGridCell/DataGridCell.tsx | 2 +- .../IndexedSnapScroller.tsx | 4 +-- packages/ui/src/NumberField/NumberField.tsx | 14 ++++---- packages/ui/src/PhoneField/PhoneField.tsx | 4 +-- .../src/SegmentedControl/SegmentedControl.tsx | 10 +++--- packages/ui/src/Select/Select.tsx | 6 ++-- .../components/SelectOption/SelectOption.tsx | 2 +- packages/ui/src/SnapScroller/SnapScroller.tsx | 2 +- .../SnapScrollerContent.tsx | 2 ++ 21 files changed, 148 insertions(+), 57 deletions(-) create mode 100644 .changeset/react-hooks-lint.md create mode 100644 packages/hooks/src/useEventCallback.ts diff --git a/.changeset/react-hooks-lint.md b/.changeset/react-hooks-lint.md new file mode 100644 index 00000000..40bbef0b --- /dev/null +++ b/.changeset/react-hooks-lint.md @@ -0,0 +1,16 @@ +--- +"@vega-ui/hooks": minor +"@vega-ui/react": patch +--- + +Enable `react-hooks` lint rules repo-wide; fix all stale-closure findings; add `useEventCallback` + +`eslint-plugin-react-hooks` was installed but none of its rules were enabled — the class of bugs behind the recent Slider and Calendar stale closures was invisible to CI. `rules-of-hooks` and `exhaustive-deps` now run as errors (with `rules-of-hooks` off for Storybook CSF `render` functions, which are false positives). + +- new `@vega-ui/hooks` export: `useEventCallback` — a stable-identity wrapper that always calls the latest handler (the industry "latest ref" pattern à la MUI/Radix/Floating UI), throws in development if called during render +- `useScrollSnap`: `onSnapChanging`/`onSnapChange`/`getSnapPoint` go through `useEventCallback`, so inline consumer callbacks no longer resubscribe the `scrollend` listener and observers on every render; the remaining deps are honest +- `Calendar`: `onSelectDay` no longer freezes the first `onChange` — it reads the latest callback through `useEventCallback` while keeping a stable identity (regression-tested by swapping `onChange` between renders) +- `NumberField`: `decrement` no longer clamps against a stale `max`; the native non-passive `wheel` listener is attached only while `changeOnWheel` is enabled and no longer resubscribes when `min`/`max`/`step` or the value change +- `useSelection`: a consumer-provided `resolveRange` no longer cascades identity changes into `select`/`expand`/`toggle` +- `Collapsible`: `onChangeHidden` fires only when `hidden` actually changes — an inline callback no longer re-triggers the notification effect on every parent render +- all remaining findings were stable-value dependencies added for free, plus three documented intentional suppressions (`useMutationObserver` per-field options deps, `createContext` shallow-by-value memoization, `SnapScrollerContent` unmount-only unregistration) diff --git a/eslint.config.mjs b/eslint.config.mjs index c0a7c74d..33840a4b 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -20,9 +20,18 @@ export default tseslint.config([ 'object-curly-spacing': ['error', 'always'], 'jsx-quotes': ['error', 'prefer-single'], 'react/react-in-jsx-scope': 'off', + 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/exhaustive-deps': 'error', '@typescript-eslint/strict-boolean-expressions': 'off', '@typescript-eslint/no-floating-promises': 'off', '@typescript-eslint/explicit-function-return-type': 'off' }, + }, + { + // Storybook CSF `render` functions use hooks but are not named like components + files: ['**/*.stories.tsx', '**/*.stories.ts'], + rules: { + 'react-hooks/rules-of-hooks': 'off', + }, } ]); \ No newline at end of file diff --git a/packages/hooks/src/index.ts b/packages/hooks/src/index.ts index 5481ee98..733e53ad 100644 --- a/packages/hooks/src/index.ts +++ b/packages/hooks/src/index.ts @@ -1,6 +1,7 @@ export * from './useResize' export * from './useIsomorphicLayoutEffect' export * from './useLatest' +export * from './useEventCallback' export * from './useControlledState' export * from './useRefMap' export * from './useSelection' diff --git a/packages/hooks/src/useEventCallback.ts b/packages/hooks/src/useEventCallback.ts new file mode 100644 index 00000000..b74aba42 --- /dev/null +++ b/packages/hooks/src/useEventCallback.ts @@ -0,0 +1,32 @@ +import { useCallback, useInsertionEffect, useRef } from 'react'; + +const renderGuard = () => { + throw new Error('useEventCallback: the handler must not be called during render.'); +}; + +/** + * Returns a function with a stable identity that always calls the latest `fn`. + * + * For event handlers only: the wrapped function is non-reactive by design and + * must never be called during render (throws in development if it is). + * Functions used while rendering should use `useCallback` with honest deps instead. + */ +export function useEventCallback( + fn: (...args: Args) => R, +): (...args: Args) => R; +export function useEventCallback( + fn?: (...args: Args) => R, +): (...args: Args) => R | undefined; +export function useEventCallback( + fn?: (...args: Args) => R, +): (...args: Args) => R | undefined { + const ref = useRef<((...args: Args) => R) | undefined>( + process.env.NODE_ENV === 'production' ? fn : (renderGuard as never), + ); + + useInsertionEffect(() => { + ref.current = fn; + }); + + return useCallback((...args: Args) => ref.current?.(...args), []); +} diff --git a/packages/hooks/src/useMutationObserver.ts b/packages/hooks/src/useMutationObserver.ts index d8a6a957..0a113d82 100644 --- a/packages/hooks/src/useMutationObserver.ts +++ b/packages/hooks/src/useMutationObserver.ts @@ -21,6 +21,9 @@ export function useMutationObserver( observer.observe(target, options ?? { childList: true }); return () => observer.disconnect(); + // options itself is intentionally not a dependency: its fields are listed one by one, + // so inline options objects don't reconnect the observer on every render + // eslint-disable-next-line react-hooks/exhaustive-deps }, [ ref, options?.attributes, diff --git a/packages/hooks/src/useScrollSnap.ts b/packages/hooks/src/useScrollSnap.ts index 78e7bca8..c08e566d 100644 --- a/packages/hooks/src/useScrollSnap.ts +++ b/packages/hooks/src/useScrollSnap.ts @@ -1,6 +1,7 @@ import { RefObject, UIEvent, useCallback, useEffect, useLayoutEffect, useRef } from 'react'; import { useMutationObserver } from './useMutationObserver'; import { useResizeObserver } from './useResizeObserver'; +import { useEventCallback } from './useEventCallback'; import { nearest } from '@vega-ui/utils'; import { useBiMap } from './useBiMap'; @@ -76,8 +77,8 @@ export const useScrollSnap = ( align = 'start', getSnapPoint, respectScrollPadding = true, - onSnapChanging, - onSnapChange, + onSnapChanging: _onSnapChanging, + onSnapChange: _onSnapChange, scrollEndDebounceMs = 80, }: UseScrollSnapOptions) => { const points = useBiMap() @@ -85,11 +86,16 @@ export const useScrollSnap = ( const setItemRef = useCallback((key: K) => (element: HTMLElement) => { items.set(key, element); - }, []) - + }, [items]) + const removeItemRef = useCallback((key: K) => { items?.deleteByKey(key) - }, []) + }, [items]) + + const onSnapChanging = useEventCallback(_onSnapChanging) + const onSnapChange = useEventCallback(_onSnapChange) + const customSnapPoint = useEventCallback(getSnapPoint) + const hasCustomSnapPoint = getSnapPoint != null const committedKey = useRef(null); const pendingKey = useRef(null); @@ -104,10 +110,10 @@ export const useScrollSnap = ( const scroller = scrollerRef.current; if (!scroller) return; - return getSnapPoint - ? getSnapPoint(scroller, element, axis, align) + return hasCustomSnapPoint + ? customSnapPoint(scroller, element, axis, align) : defaultGetSnapPoint(scroller, element, axis, align, respectScrollPadding); - }, [respectScrollPadding, axis, align]) + }, [respectScrollPadding, axis, align, hasCustomSnapPoint, customSnapPoint, scrollerRef]) const getPointed = useCallback((scroller?: HTMLElement) => { const s = scroller ?? scrollerRef.current; @@ -125,7 +131,7 @@ export const useScrollSnap = ( const element = (key !== null ? items.getByKey(key) : null) ?? null return { key, element } - }, [axis]) + }, [axis, points, items, scrollerRef]) const measure = useCallback(() => { points.clear(); @@ -182,7 +188,7 @@ export const useScrollSnap = ( committedKey.current = key; pendingKey.current = key; - if (element && key !== null) onSnapChange?.(element, key); + if (element && key !== null) onSnapChange(element, key); }, [onSnapChange, getPointed]) const scheduleCommitFallback = useCallback(() => { @@ -206,13 +212,13 @@ export const useScrollSnap = ( if (key !== pendingKey.current) { pendingKey.current = key; - if (element && key !== null) onSnapChanging?.(element, key); + if (element && key !== null) onSnapChanging(element, key); } // Native-like: commit only on real scroll end when possible if (!hasNativeScrollEndRef.current) scheduleCommitFallback(); }); - }, [onSnapChanging, scheduleMeasure, scheduleCommitFallback]); + }, [onSnapChanging, scheduleMeasure, scheduleCommitFallback, getPointed, points]); const scrollToElement = useCallback((el: HTMLElement, behavior: ScrollBehavior = 'smooth') => { const scroller = scrollerRef.current; @@ -231,14 +237,14 @@ export const useScrollSnap = ( } scroller.scrollTo({ left: target, behavior }); - }, [axis, scheduleMeasure, calculateSnapPoint]) + }, [axis, scheduleMeasure, calculateSnapPoint, items, points, scrollerRef]) const scrollToElementByKey = useCallback((key: K, behavior: ScrollBehavior = 'smooth') => { const item = items.getByKey(key as K) if (!item) return scrollToElement(item, behavior) - }, [scrollToElement]) + }, [scrollToElement, items]) const getCommited = useCallback(() => committedKey.current, []) const getPending = useCallback(() => pendingKey.current, []) diff --git a/packages/hooks/src/useSelection.ts b/packages/hooks/src/useSelection.ts index 159468f5..9681269d 100644 --- a/packages/hooks/src/useSelection.ts +++ b/packages/hooks/src/useSelection.ts @@ -1,6 +1,7 @@ import { useCallback, useMemo } from 'react'; import { compare as defaultCompare } from '@vega-ui/utils'; import { useControlledState } from './useControlledState'; +import { useEventCallback } from './useEventCallback'; export type Selection = 'single' | 'multiple' | 'range' export type SelectedValue = M extends 'single' ? K : K[] @@ -36,6 +37,7 @@ export const useSelection = ({ const eq = useMemo(() => equals ?? ((a: K, b: K) => Object.is(a, b)), [equals]) const comparator = useMemo(() => compare ?? (defaultCompare as unknown as (a: K, b: K) => -1 | 0 | 1), [compare]) + const resolveRangeOrDefault = useEventCallback(resolveRange ?? ((start: K, end: K): K[] => [start, end])) const edges = useCallback((): [K, K] | [] => { if (selection !== 'range') return [] @@ -99,7 +101,7 @@ export const useSelection = ({ setSelected([key] as SelectedValue) } - }, [eq, selected]) + }, [eq, selected, selection, setSelected]) const select = useCallback((key: K) => { if (isDisabled(key)) return @@ -116,9 +118,7 @@ export const useSelection = ({ if (eq(start, key)) return if (array.length === 1) { - const range = resolveRange - ? resolveRange(start, key) - : [start, key] + const range = resolveRangeOrDefault(start, key) setSelected(range as SelectedValue) return } @@ -136,12 +136,11 @@ export const useSelection = ({ } setSelected(key as SelectedValue) - }, [selection, selected, eq, resolveRange, isDisabled]) + }, [selection, selected, eq, resolveRangeOrDefault, isDisabled, setSelected]) const resolvedRange = useCallback((start: K, end: K): SelectedValue => { - const value = resolveRange ? resolveRange(start, end) : [start, end] - return value as SelectedValue - }, [resolveRange]) + return resolveRangeOrDefault(start, end) as SelectedValue + }, [resolveRangeOrDefault]) const expand = useCallback((key: K, edge?: 0 | 1) => { if (selection !== 'range') return @@ -178,7 +177,7 @@ export const useSelection = ({ setSelected(resolvedRange(start, key)) return } - }, [selection, comparator, eq, resolvedRange, edges]) + }, [selection, comparator, eq, resolvedRange, edges, setSelected]) const toggle = useCallback((key: K) => { if (isSelected(key)) unselect(key) diff --git a/packages/react-context/src/createContext.tsx b/packages/react-context/src/createContext.tsx index d0c35eb4..fcb4d606 100644 --- a/packages/react-context/src/createContext.tsx +++ b/packages/react-context/src/createContext.tsx @@ -5,6 +5,9 @@ export const createContext = (name: string, defaultCont Context.displayName = name + 'Context'; const Provider: FC> = ({ children, ...props }) => { + // Intentional shallow-by-value memoization: the context value keeps its identity + // as long as every prop is referentially equal. The dep list can't be a literal here. + // eslint-disable-next-line react-hooks/exhaustive-deps const value = useMemo(() => props, Object.values(props)) as D; return {children}; }; diff --git a/packages/ui/src/Calendar/Calendar.tsx b/packages/ui/src/Calendar/Calendar.tsx index d5a5bd89..6b8669c3 100644 --- a/packages/ui/src/Calendar/Calendar.tsx +++ b/packages/ui/src/Calendar/Calendar.tsx @@ -1,7 +1,7 @@ import { useCallback, useRef } from 'react'; import { IndexedSnapScrollerApiRef } from '../IndexedSnapScroller'; import { CalendarProvider } from './contexts'; -import { useControlledState } from '@vega-ui/hooks'; +import { useControlledState, useEventCallback } from '@vega-ui/hooks'; import { CalendarDatesDisabled, CalendarPicker, CalendarSelection, CalendarValue } from './types'; import { DataGridApiRef } from '../DataGrid'; import { CalendarBase, CalendarBaseProps } from '../CalendarBase'; @@ -202,7 +202,7 @@ export const Calendar = ({ const openDayPicker = useCallback(() => { setActivePicker('day') - }, []) + }, [setActivePicker]) const closePicker = useCallback(() => { openDayPicker() @@ -252,10 +252,10 @@ export const Calendar = ({ focusAvailable(value, clampedMonth) } - const onSelectDay = useCallback((day: number | number[]) => { + const onSelectDay = useEventCallback((day: number | number[]) => { const value = Array.isArray(day) ? day.map(d => new Date(d)) : new Date(day) onChange?.(value as CalendarValue) - }, []) + }) const toggleMonthPicker = useCallback(() => { if (activePicker === 'month') { @@ -265,7 +265,7 @@ export const Calendar = ({ setActivePicker('month') requestAnimationFrame(() => focusPickerValue(monthPickerApiRef.current, date.getMonth())) - }, [activePicker, date, openDayPicker]) + }, [activePicker, date, openDayPicker, setActivePicker]) const toggleYearPicker = useCallback(() => { if (activePicker === 'year') { @@ -275,7 +275,7 @@ export const Calendar = ({ setActivePicker('year') requestAnimationFrame(() => focusPickerValue(yearPickerApiRef.current, date.getFullYear())) - }, [activePicker, date, openDayPicker]) + }, [activePicker, date, openDayPicker, setActivePicker]) const nextPeriod = useCallback(() => scrollerApiRef.current?.next(), []) const nextYearGroup = useCallback(() => yearScrollerApiRef.current?.next(), []) diff --git a/packages/ui/src/Calendar/__tests__/Calendar.browser.test.tsx b/packages/ui/src/Calendar/__tests__/Calendar.browser.test.tsx index 7f8e33bf..7fa47d5c 100644 --- a/packages/ui/src/Calendar/__tests__/Calendar.browser.test.tsx +++ b/packages/ui/src/Calendar/__tests__/Calendar.browser.test.tsx @@ -420,6 +420,24 @@ describe('Calendar', () => { }); describe('Edge Cases', () => { + it('calls the latest onChange after it changes between renders', async () => { + const initialOnChange = vi.fn(); + const nextOnChange = vi.fn(); + + const r = render(); + r.rerender(); + + const day = r + .getAllByText('15') + .find((el) => el.closest('[role="gridcell"]')?.getAttribute('aria-disabled') === 'false'); + + await userEvent.click(day!); + + expect(nextOnChange).toHaveBeenCalledTimes(1); + expect(nextOnChange).toHaveBeenCalledWith(expect.any(Date)); + expect(initialOnChange).not.toHaveBeenCalled(); + }); + it('supports multiple calendars rendered together: renders two month picker buttons', async () => { const r = render( <> diff --git a/packages/ui/src/Collapsible/Collapsible.tsx b/packages/ui/src/Collapsible/Collapsible.tsx index 56047445..e16db803 100644 --- a/packages/ui/src/Collapsible/Collapsible.tsx +++ b/packages/ui/src/Collapsible/Collapsible.tsx @@ -1,7 +1,7 @@ 'use client'; import { FC, ReactNode, useCallback, useEffect, useId, useState } from 'react'; import { CollapsibleProvider } from './contexts'; -import { useControlledState } from '@vega-ui/hooks'; +import { useControlledState, useEventCallback } from '@vega-ui/hooks'; export interface CollapsibleProps { /** @@ -51,7 +51,7 @@ export const Collapsible: FC = ({ open: controlledOpen, defaultOpen = false, onChangeOpen: onControlledChangeOpen, - onChangeHidden, + onChangeHidden: _onChangeHidden, contentId, children }) => { @@ -68,8 +68,10 @@ export const Collapsible: FC = ({ onChangeOpen?.(false) }, [onChangeOpen]) + const onChangeHidden = useEventCallback(_onChangeHidden) + useEffect(() => { - onChangeHidden?.(hidden) + onChangeHidden(hidden) }, [hidden, onChangeHidden]) const onOpenContent = useCallback(() => { diff --git a/packages/ui/src/DataGrid/DataGrid.tsx b/packages/ui/src/DataGrid/DataGrid.tsx index 83da1901..dd0b1412 100644 --- a/packages/ui/src/DataGrid/DataGrid.tsx +++ b/packages/ui/src/DataGrid/DataGrid.tsx @@ -104,14 +104,14 @@ export const DataGrid = ({ const [active, setActive] = useControlledState(_active, defaultActive ?? '' as K, onChangeActive) - useImperativeHandle(apiRef, () => ({ grid, scopes }), []) + useImperativeHandle(apiRef, () => ({ grid, scopes }), [grid, scopes]) const setItemRef = useCallback((coordinates: DataGridCoordinates, key: K, scope: DataGridScope) => (element: HTMLDivElement) => { grid.addNode(coordinates, key, element) const currentScope = scopes.get(scope) ?? [] scopes.set(scope, [...currentScope, key]) - }, []) + }, [grid, scopes]) const removeItemRef = useCallback((coordinates: DataGridCoordinates, key: K, scope: DataGridScope) => { grid.removeNode(coordinates) @@ -119,7 +119,7 @@ export const DataGrid = ({ const currentScope = scopes.get(scope) ?? [] scopes.set(scope, currentScope.filter(v => v !== key)) if (scopes.get(scope)?.length === 0) scopes.delete(scope) - }, []) + }, [grid, scopes]) const changeFocus = (node: MatrixNode) => { node.payload?.focus() diff --git a/packages/ui/src/DataGrid/components/DataGridCell/DataGridCell.tsx b/packages/ui/src/DataGrid/components/DataGridCell/DataGridCell.tsx index 29bad67f..fa709e34 100644 --- a/packages/ui/src/DataGrid/components/DataGridCell/DataGridCell.tsx +++ b/packages/ui/src/DataGrid/components/DataGridCell/DataGridCell.tsx @@ -68,7 +68,7 @@ export const DataGridCell: FC> = ({ return () => { removeItemRef([row, col], key, scope) } - }, [row, col, key]); + }, [row, col, key, scope, removeItemRef]); const onFocus = () => { changeActive(key) diff --git a/packages/ui/src/IndexedSnapScroller/IndexedSnapScroller.tsx b/packages/ui/src/IndexedSnapScroller/IndexedSnapScroller.tsx index 1d43151b..a0b3425a 100644 --- a/packages/ui/src/IndexedSnapScroller/IndexedSnapScroller.tsx +++ b/packages/ui/src/IndexedSnapScroller/IndexedSnapScroller.tsx @@ -142,7 +142,7 @@ export const IndexedSnapScroller: FC const start = indexes[0] reset(computeStart(start, key, nextIndex)) - }, [indexes]) + }, [indexes, reset]) const preserveScrollPosition = useCallback(() => { if (!preserveScroll) return; @@ -165,7 +165,7 @@ export const IndexedSnapScroller: FC // Sync controlled state if (index === undefined || (index === api.getPending() || index === api.getCommited())) return setIndexTo(index) - }, [index]) + }, [index, setIndexTo]) const offset = (value: number) => { onOffset?.(value) diff --git a/packages/ui/src/NumberField/NumberField.tsx b/packages/ui/src/NumberField/NumberField.tsx index b88c166e..90b82c4b 100644 --- a/packages/ui/src/NumberField/NumberField.tsx +++ b/packages/ui/src/NumberField/NumberField.tsx @@ -10,6 +10,7 @@ import { import { TextField, TextFieldProps } from '../TextField'; import style from './style.module.css' import { clamp, csx, dispatchEvents, mergeRefs, setValue } from '@vega-ui/utils'; +import { useEventCallback } from '@vega-ui/hooks'; import { getNumberMaskOptions, getNumberValue } from './helpers'; import { useMaskito } from '@maskito/react'; import { NumberFieldProvider } from './contexts'; @@ -166,7 +167,7 @@ export const NumberField: FC = ({ const nextValue = clamp(min, getNumberValue(value) - step, max); changeValue(nextValue) - }, [min, step, changeValue]) + }, [min, max, step, changeValue]) const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'ArrowUp') { @@ -180,21 +181,20 @@ export const NumberField: FC = ({ } } - const onWheel = useCallback((e: WheelEvent) => { - if (!changeOnWheel) return + const onWheel = useEventCallback((e: WheelEvent) => { e.preventDefault() - + if (e.deltaY < 0) increment() else decrement() - }, [increment, decrement, changeOnWheel]) + }) useEffect(() => { const elem = wrapperRef.current - if (!elem) return + if (!elem || !changeOnWheel) return elem.addEventListener('wheel', onWheel) return () => elem.removeEventListener('wheel', onWheel) - }, [onWheel]); + }, [onWheel, changeOnWheel]); return ( = ({ }) innerRef.current?.focus() - }, []) + }, [setCode]) const onInput = useCallback((e: FormEvent) => { const value = e.currentTarget.value @@ -73,7 +73,7 @@ export const PhoneField: FC = ({ const typedCode = asYouType.getCountry() if (typedCode !== undefined && typedCode !== code) setCode(typedCode) - }, [code]) + }, [code, setCode]) return ( = ({ const [indicatorSize, setIndicatorSize] = useState(0) const [indicatorOffsetLeft, setIndicatorOffsetLeft] = useState(0) - const syncIndicatorParams = (value: SegmentedControlValue) => { + const syncIndicatorParams = useCallback((value: SegmentedControlValue) => { const item = getItem(value) if (!item) return; - + setIndicatorOffsetLeft(item.offsetLeft) setIndicatorSize(item.clientWidth) - } + }, [getItem]) const onChange = (e: ChangeEvent) => { const value = e.currentTarget.value @@ -96,7 +96,7 @@ export const SegmentedControl: FC = ({ useLayoutEffect(() => { if (value !== undefined) syncIndicatorParams(value) - }, [value]) + }, [value, syncIndicatorParams]) useResizeObserver(controlRef, () => syncIndicatorParams(value)) diff --git a/packages/ui/src/Select/Select.tsx b/packages/ui/src/Select/Select.tsx index 606c0f49..fb3364b4 100644 --- a/packages/ui/src/Select/Select.tsx +++ b/packages/ui/src/Select/Select.tsx @@ -212,7 +212,7 @@ export const Select = ({ setSelectedIndex(index) select(value) setOpen(false) - }, []) + }, [select, setOpen]) const { status } = useTransitionStatus(context); @@ -220,12 +220,12 @@ export const Select = ({ const { index, ...data } = option indexValueMap.set(index, data.value) setOptions(p => [...p, option]) - }, []) + }, [indexValueMap]) const removeOption = useCallback((option: SelectNativeOption) => { setOptions(p => p.filter(v => v.value !== option.value)) indexValueMap.delete(option.index) - }, []) + }, [indexValueMap]) return ( ({ addOption({ value, disabled: disabledOption, label, index }) return () => removeOption({ value, disabled: disabledOption, label, index }) - }, [value, disabledOption, index]) + }, [value, disabledOption, index, addOption, removeOption]) return ( <> diff --git a/packages/ui/src/SnapScroller/SnapScroller.tsx b/packages/ui/src/SnapScroller/SnapScroller.tsx index 929a2faa..db6a77d8 100644 --- a/packages/ui/src/SnapScroller/SnapScroller.tsx +++ b/packages/ui/src/SnapScroller/SnapScroller.tsx @@ -125,7 +125,7 @@ export const SnapScroller: FC> = ({ didInitScroll.current = true; if (defaultIndex !== undefined) scrollToElementByKey(defaultIndex, 'instant'); - }, [defaultIndex]); + }, [defaultIndex, scrollToElementByKey]); return ( diff --git a/packages/ui/src/SnapScroller/components/SnapScrollerContent/SnapScrollerContent.tsx b/packages/ui/src/SnapScroller/components/SnapScrollerContent/SnapScrollerContent.tsx index 1726e071..e82c2f70 100644 --- a/packages/ui/src/SnapScroller/components/SnapScrollerContent/SnapScrollerContent.tsx +++ b/packages/ui/src/SnapScroller/components/SnapScrollerContent/SnapScrollerContent.tsx @@ -41,6 +41,8 @@ export const SnapScrollerContent: FC return () => { removeItemRef(index) } + // unmount-only: a cleanup on index change would delete freshly re-keyed neighbor entries + // eslint-disable-next-line react-hooks/exhaustive-deps }, []) return (