diff --git a/packages/react/src/select/list/SelectList.tsx b/packages/react/src/select/list/SelectList.tsx index 118a01055e7..e490ef8053a 100644 --- a/packages/react/src/select/list/SelectList.tsx +++ b/packages/react/src/select/list/SelectList.tsx @@ -44,7 +44,7 @@ export const SelectList = React.forwardRef(function SelectList( hasScrollArrows && openMethod !== 'touch' ? styleDisableScrollbar.className : undefined, }; - const setListElement = useStableCallback((element: HTMLElement | null) => { + const setListElement = useStableCallback((element: HTMLDivElement | null) => { store.set('listElement', element); }); diff --git a/packages/react/src/tooltip/root/TooltipRoot.tsx b/packages/react/src/tooltip/root/TooltipRoot.tsx index 20fd13998d4..ccc5aef185f 100644 --- a/packages/react/src/tooltip/root/TooltipRoot.tsx +++ b/packages/react/src/tooltip/root/TooltipRoot.tsx @@ -93,7 +93,9 @@ export const TooltipRoot = fastComponent(function TooltipRoot( // 2) Closing because another tooltip opened (reason === 'none') // Otherwise, allow the animation to play. In particular, do not disable animations // during the 'ending' phase unless it's due to a sibling opening. - const previousInstantTypeRef = React.useRef(null); + const previousInstantTypeRef = React.useRef<'delay' | 'focus' | 'dismiss' | undefined | null>( + null, + ); useIsoLayoutEffect(() => { if (openState && disabled) { diff --git a/packages/utils/src/store/ReactStore.test.tsx b/packages/utils/src/store/ReactStore.test.tsx index e03fa26cd1d..8b275d9a791 100644 --- a/packages/utils/src/store/ReactStore.test.tsx +++ b/packages/utils/src/store/ReactStore.test.tsx @@ -261,7 +261,7 @@ describe('ReactStore', () => { it('supports nested stores as state values', async () => { type ParentState = { count: number }; - type ChildState = { count: number; parent?: ReactStore }; + type ChildState = { count: number; parent?: ReactStore }; const parentSelectors = { count: (state: ParentState) => state.count }; const childSelectors = { diff --git a/packages/utils/src/store/Store.test.ts b/packages/utils/src/store/Store.test.ts new file mode 100644 index 00000000000..893c3205f99 --- /dev/null +++ b/packages/utils/src/store/Store.test.ts @@ -0,0 +1,183 @@ +import { expect, vi } from 'vitest'; +import { lruMemoize } from 'reselect'; +import { Store } from './Store'; +import { createSelector } from './createSelector'; +import { createSelectorMemoized, createSelectorMemoizedWithOptions } from './createSelectorMemoized'; + +describe('Store', () => { + describe('Store.create', () => { + it('returns a Store instance seeded with the given state', () => { + const store = Store.create({ value: 1, label: 'a' }); + + expect(store).toBeInstanceOf(Store); + expect(store.state).toEqual({ value: 1, label: 'a' }); + }); + + it('produces an independent instance per call', () => { + const first = Store.create({ value: 0 }); + const second = Store.create({ value: 0 }); + + first.set('value', 1); + + expect(first.state.value).toBe(1); + expect(second.state.value).toBe(0); + }); + }); +}); + +describe('createSelector', () => { + it('returns the input function when called with a single selector', () => { + const fn = (state: { value: number }) => state.value; + const selector = createSelector(fn); + + expect(selector).toBe(fn); + expect(selector({ value: 5 })).toBe(5); + }); + + it('supports six selectors plus a combiner', () => { + type S = { a: number; b: number; c: number; d: number; e: number; f: number }; + const state: S = { a: 1, b: 2, c: 3, d: 4, e: 5, f: 6 }; + + const selector = createSelector( + (s: S) => s.a, + (s: S) => s.b, + (s: S) => s.c, + (s: S) => s.d, + (s: S) => s.e, + (s: S) => s.f, + (a, b, c, d, e, f) => a + b + c + d + e + f, + ); + + expect(selector(state)).toBe(21); + }); + + it('supports seven selectors plus a combiner', () => { + type S = { a: number; b: number; c: number; d: number; e: number; f: number; g: number }; + const state: S = { a: 1, b: 2, c: 3, d: 4, e: 5, f: 6, g: 7 }; + + const selector = createSelector( + (s: S) => s.a, + (s: S) => s.b, + (s: S) => s.c, + (s: S) => s.d, + (s: S) => s.e, + (s: S) => s.f, + (s: S) => s.g, + (a, b, c, d, e, f, g) => a + b + c + d + e + f + g, + ); + + expect(selector(state)).toBe(28); + }); + + it('passes extra args through to every input selector and to the combiner', () => { + type S = { value: number }; + const state: S = { value: 10 }; + + const selector = createSelector( + (s: S, multiplier: number) => s.value * multiplier, + (s: S, _multiplier: number, offset: number) => s.value + offset, + (scaled, shifted, multiplier, offset) => ({ scaled, shifted, multiplier, offset }), + ); + + expect(selector(state, 3, 7)).toEqual({ scaled: 30, shifted: 17, multiplier: 3, offset: 7 }); + }); + + it('throws when given more selectors than are supported', () => { + const fn = (s: any) => s; + + expect(() => + // @ts-expect-error intentionally over the supported arity + createSelector(fn, fn, fn, fn, fn, fn, fn, fn, fn), + ).toThrow('Unsupported number of selectors'); + }); +}); + +describe('createSelectorMemoized', () => { + it('uses an identity input selector when only a combiner is provided', () => { + type S = { value: number }; + const combiner = vi.fn((s: S) => ({ doubled: s.value * 2 })); + + const selector = createSelectorMemoized(combiner); + + const state: S = { value: 4 }; + expect(selector(state)).toEqual({ doubled: 8 }); + expect(combiner).toHaveBeenCalledTimes(1); + + expect(selector(state)).toEqual({ doubled: 8 }); + expect(combiner).toHaveBeenCalledTimes(1); + }); + + it('re-runs the combiner when input selector results change', () => { + type S = { a: number; b: number }; + const combiner = vi.fn((a: number, b: number) => ({ sum: a + b })); + + const selector = createSelectorMemoized( + (state: S) => state.a, + (state: S) => state.b, + combiner, + ); + + const state: S = { a: 1, b: 2 }; + expect(selector(state)).toEqual({ sum: 3 }); + expect(combiner).toHaveBeenCalledTimes(1); + + expect(selector(state)).toEqual({ sum: 3 }); + expect(combiner).toHaveBeenCalledTimes(1); + + const next: S = { a: 5, b: 2 }; + expect(selector(next)).toEqual({ sum: 7 }); + expect(combiner).toHaveBeenCalledTimes(2); + }); + + it('caches separately per state identity', () => { + type S = { value: number }; + const combiner = vi.fn((value: number) => ({ value })); + + const selector = createSelectorMemoized((state: S) => state.value, combiner); + + const a: S = { value: 1 }; + const b: S = { value: 1 }; + + selector(a); + selector(b); + + expect(combiner).toHaveBeenCalledTimes(2); + }); +}); + +describe('createSelectorMemoizedWithOptions', () => { + it('produces a working factory when no options are provided', () => { + type S = { value: number }; + const combiner = vi.fn((value: number) => ({ value })); + + const selector = createSelectorMemoizedWithOptions()((state: S) => state.value, combiner); + + const state: S = { value: 3 }; + expect(selector(state)).toEqual({ value: 3 }); + expect(selector(state)).toEqual({ value: 3 }); + expect(combiner).toHaveBeenCalledTimes(1); + }); + + it('forwards options through to the underlying reselect creator', () => { + type S = { value: number }; + const inputSelector = vi.fn((state: S) => state.value); + const combiner = vi.fn((value: number) => ({ value })); + + const selector = createSelectorMemoizedWithOptions({ + argsMemoize: lruMemoize, + argsMemoizeOptions: { equalityCheck: () => false, maxSize: 1 }, + devModeChecks: { inputStabilityCheck: 'never', identityFunctionCheck: 'never' }, + })(inputSelector, combiner); + + const state: S = { value: 7 }; + selector(state); + const callsAfterFirst = inputSelector.mock.calls.length; + selector(state); + + // The custom argsMemoize never considers args equal, so input selectors + // re-run on every call (defaults would have produced a cache hit instead). + expect(inputSelector.mock.calls.length).toBeGreaterThan(callsAfterFirst); + // The combiner result is still memoized because the input value is unchanged. + expect(combiner).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/utils/src/store/Store.ts b/packages/utils/src/store/Store.ts index b53f4cc70fe..96a4c46b14a 100644 --- a/packages/utils/src/store/Store.ts +++ b/packages/utils/src/store/Store.ts @@ -7,6 +7,10 @@ type Listener = (state: T) => void; * It uses an observer pattern to notify subscribers when the state changes. */ export class Store { + static create(state: T) { + return new Store(state); + } + /** * The current state of the store. * This property is updated immediately when the state changes as a result of calling {@link setState}, {@link update}, or {@link set}. @@ -92,7 +96,7 @@ export class Store { * @param key The key in the store's state to update. * @param value The new value to set for the specified key. */ - set(key: keyof State, value: T) { + set(key: Key, value: T) { if (!Object.is(this.state[key], value)) { this.setState({ ...this.state, [key]: value }); } diff --git a/packages/utils/src/store/createSelector.ts b/packages/utils/src/store/createSelector.ts index 8f6af3bda73..87826f52032 100644 --- a/packages/utils/src/store/createSelector.ts +++ b/packages/utils/src/store/createSelector.ts @@ -58,7 +58,7 @@ type MergeParams< * * The combiner function can have up to three additional parameters, but it **cannot have optional or default parameters**. * - * This function accepts up to six functions and combines them into a single selector function. + * This function accepts up to seven input selectors plus a combiner and combines them into a single selector function. * The resulting selector will take the state from the combined selectors and any additional parameters required by the combiner. * * The return type of the resulting selector is determined by the return type of the combiner function. @@ -83,6 +83,8 @@ export const createSelector = (( d?: Function, e?: Function, f?: Function, + g?: Function, + h?: Function, ...other: any[] ) => { if (other.length > 0) { @@ -91,7 +93,28 @@ export const createSelector = (( let selector: any; - if (a && b && c && d && e && f) { + if (a && b && c && d && e && f && g && h) { + selector = (state: any, a1: any, a2: any, a3: any) => { + const va = a(state, a1, a2, a3); + const vb = b(state, a1, a2, a3); + const vc = c(state, a1, a2, a3); + const vd = d(state, a1, a2, a3); + const ve = e(state, a1, a2, a3); + const vf = f(state, a1, a2, a3); + const vg = g(state, a1, a2, a3); + return h(va, vb, vc, vd, ve, vf, vg, a1, a2, a3); + }; + } else if (a && b && c && d && e && f && g) { + selector = (state: any, a1: any, a2: any, a3: any) => { + const va = a(state, a1, a2, a3); + const vb = b(state, a1, a2, a3); + const vc = c(state, a1, a2, a3); + const vd = d(state, a1, a2, a3); + const ve = e(state, a1, a2, a3); + const vf = f(state, a1, a2, a3); + return g(va, vb, vc, vd, ve, vf, a1, a2, a3); + }; + } else if (a && b && c && d && e && f) { selector = (state: any, a1: any, a2: any, a3: any) => { const va = a(state, a1, a2, a3); const vb = b(state, a1, a2, a3); diff --git a/packages/utils/src/store/createSelectorMemoized.ts b/packages/utils/src/store/createSelectorMemoized.ts index 5b80f269a82..921c9e28a46 100644 --- a/packages/utils/src/store/createSelectorMemoized.ts +++ b/packages/utils/src/store/createSelectorMemoized.ts @@ -1,5 +1,5 @@ import { lruMemoize, createSelectorCreator } from 'reselect'; -import type { Selector } from 'reselect'; +import type { OverrideMemoizeOptions, UnknownMemoizer, Selector } from 'reselect'; import type { CreateSelectorFunction } from './createSelector'; /* eslint-disable no-underscore-dangle */ // __cacheKey__ @@ -14,86 +14,102 @@ const reselectCreateSelector = createSelectorCreator({ type SelectorWithArgs = ReturnType & { selectorArgs: any[3] }; -export const createSelectorMemoized: CreateSelectorFunction = (...selectors: any[]) => { - type CacheKey = { id: number }; +export const createSelectorMemoizedWithOptions = + (options?: OverrideMemoizeOptions): CreateSelectorFunction => + (...inputs: any[]) => { + type CacheKey = { id: number }; - const cache = new WeakMap(); - let nextCacheId = 1; + const cache = new WeakMap(); + let nextCacheId = 1; - const combiner = selectors[selectors.length - 1]; - const nSelectors = selectors.length - 1 || 1; - // (s1, s2, ..., sN, a1, a2, a3) => { ... } - const argsLength = combiner.length - nSelectors; + const combiner = inputs[inputs.length - 1]; + const nSelectors = inputs.length - 1 || 1; + // (s1, s2, ..., sN, a1, a2, a3) => { ... } + const argsLength = Math.max(combiner.length - nSelectors, 0); - if (argsLength > 3) { - throw new Error('Unsupported number of arguments'); - } - - const selector = (state: any, a1: any, a2: any, a3: any) => { - let cacheKey = state.__cacheKey__; - if (!cacheKey) { - cacheKey = { id: nextCacheId }; - state.__cacheKey__ = cacheKey; - nextCacheId += 1; + if (argsLength > 3) { + throw new Error('Unsupported number of arguments'); } - let fn = cache.get(cacheKey); - if (!fn) { - let reselectArgs: Array | (() => unknown) | typeof combiner> = selectors; - const selectorArgs = [undefined, undefined, undefined]; - switch (argsLength) { - case 0: - break; - case 1: { - reselectArgs = [...selectors.slice(0, -1), () => selectorArgs[0], combiner]; - break; - } - case 2: { - reselectArgs = [ - ...selectors.slice(0, -1), - () => selectorArgs[0], - () => selectorArgs[1], - combiner, - ]; - break; + const selector = (state: any, a1: any, a2: any, a3: any) => { + let cacheKey = state.__cacheKey__; + if (!cacheKey) { + cacheKey = { id: nextCacheId }; + state.__cacheKey__ = cacheKey; + nextCacheId += 1; + } + + let fn = cache.get(cacheKey); + if (!fn) { + const selectors = inputs.length === 1 ? [(x: any) => x, combiner] : inputs; + let reselectArgs: Array | (() => unknown) | typeof combiner> = selectors; + const selectorArgs = [undefined, undefined, undefined]; + switch (argsLength) { + case 0: + break; + case 1: { + reselectArgs = [...selectors.slice(0, -1), () => selectorArgs[0], combiner]; + break; + } + case 2: { + reselectArgs = [ + ...selectors.slice(0, -1), + () => selectorArgs[0], + () => selectorArgs[1], + combiner, + ]; + break; + } + case 3: { + reselectArgs = [ + ...selectors.slice(0, -1), + () => selectorArgs[0], + () => selectorArgs[1], + () => selectorArgs[2], + combiner, + ]; + break; + } + default: + throw new Error('Unsupported number of arguments'); } - case 3: { - reselectArgs = [ - ...selectors.slice(0, -1), - () => selectorArgs[0], - () => selectorArgs[1], - () => selectorArgs[2], - combiner, - ]; - break; + if (options) { + reselectArgs = [...reselectArgs, options]; } - default: - throw new Error('Unsupported number of arguments'); - } - fn = reselectCreateSelector(...(reselectArgs as any)) as unknown as SelectorWithArgs; - fn.selectorArgs = selectorArgs; + fn = reselectCreateSelector(...(reselectArgs as any)) as unknown as SelectorWithArgs; + fn.selectorArgs = selectorArgs; - cache.set(cacheKey, fn); - } + cache.set(cacheKey, fn); + } - fn.selectorArgs[0] = a1; - fn.selectorArgs[1] = a2; - fn.selectorArgs[2] = a3; + /* eslint-disable no-fallthrough */ - switch (argsLength) { - case 0: - return fn(state); - case 1: - return fn(state, a1); - case 2: - return fn(state, a1, a2); - case 3: - return fn(state, a1, a2, a3); - default: - throw /* minify-error-disabled */ new Error('unreachable'); - } + switch (argsLength) { + case 3: + fn.selectorArgs[2] = a3; + case 2: + fn.selectorArgs[1] = a2; + case 1: + fn.selectorArgs[0] = a1; + case 0: + default: + } + switch (argsLength) { + case 0: + return fn(state); + case 1: + return fn(state, a1); + case 2: + return fn(state, a1, a2); + case 3: + return fn(state, a1, a2, a3); + default: + throw /* minify-error-disabled */ new Error('unreachable'); + } + }; + + return selector as any; }; - return selector as any; -}; +export const createSelectorMemoized: CreateSelectorFunction = createSelectorMemoizedWithOptions();