Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/react/src/select/list/SelectList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down
4 changes: 3 additions & 1 deletion packages/react/src/tooltip/root/TooltipRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,9 @@ export const TooltipRoot = fastComponent(function TooltipRoot<Payload>(
// 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<string | undefined | null>(null);
const previousInstantTypeRef = React.useRef<'delay' | 'focus' | 'dismiss' | undefined | null>(
null,
);

useIsoLayoutEffect(() => {
if (openState && disabled) {
Expand Down
2 changes: 1 addition & 1 deletion packages/utils/src/store/ReactStore.test.tsx
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New public store behavior is effectively untested.
The only changed test line widens an existing nested-store type. The PR adds/exports useStoreEffect, Store.create, createSelectorMemoizedWithOptions, single-combiner handling in createSelectorMemoized, and expanded createSelector arity, but none of those behaviors have focused regression tests.

Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ describe('ReactStore', () => {

it('supports nested stores as state values', async () => {
type ParentState = { count: number };
type ChildState = { count: number; parent?: ReactStore<ParentState> };
type ChildState = { count: number; parent?: ReactStore<ParentState, any, any> };

const parentSelectors = { count: (state: ParentState) => state.count };
const childSelectors = {
Expand Down
183 changes: 183 additions & 0 deletions packages/utils/src/store/Store.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
6 changes: 5 additions & 1 deletion packages/utils/src/store/Store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ type Listener<T> = (state: T) => void;
* It uses an observer pattern to notify subscribers when the state changes.
*/
export class Store<State> {
static create<T>(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}.
Expand Down Expand Up @@ -92,7 +96,7 @@ export class Store<State> {
* @param key The key in the store's state to update.
* @param value The new value to set for the specified key.
*/
set<T>(key: keyof State, value: T) {
set<Key extends keyof State, T extends State[Key]>(key: Key, value: T) {
if (!Object.is(this.state[key], value)) {
this.setState({ ...this.state, [key]: value });
}
Expand Down
27 changes: 25 additions & 2 deletions packages/utils/src/store/createSelector.ts
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JSDoc is stale after the arity change

Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -83,6 +83,8 @@ export const createSelector = ((
d?: Function,
e?: Function,
f?: Function,
g?: Function,
h?: Function,
...other: any[]
) => {
if (other.length > 0) {
Expand All @@ -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);
Expand Down
Loading
Loading