From af285af72aa7ac0f3facfea27ee7f33833e8d9eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dudak?= Date: Wed, 22 Oct 2025 13:11:37 +0200 Subject: [PATCH 01/12] [utils] Add infrastructure to support nested stores --- docs/src/error-codes.json | 3 +- packages/utils/src/store/ReactStore.test.tsx | 306 ++++++++++++++++++- packages/utils/src/store/ReactStore.ts | 101 +++++- packages/utils/src/store/Store.ts | 12 +- packages/utils/tsconfig.build.json | 3 +- 5 files changed, 410 insertions(+), 15 deletions(-) diff --git a/docs/src/error-codes.json b/docs/src/error-codes.json index ad29e80f77d..cd4df62a9c7 100644 --- a/docs/src/error-codes.json +++ b/docs/src/error-codes.json @@ -80,5 +80,6 @@ "79": "Base UI: must be used within or provided with a handle.", "80": "Base UI: PopoverHandle.open: No trigger found with id \"%s\".", "81": "Base UI: TooltipHandle.open: No trigger found with id \"%s\".", - "82": "Base UI: must be either used within a component or provided with a handle." + "82": "Base UI: must be either used within a component or provided with a handle.", + "83": "Base UI: Selector for key \"%s\" is not defined." } diff --git a/packages/utils/src/store/ReactStore.test.tsx b/packages/utils/src/store/ReactStore.test.tsx index cf8e4811dbc..3242b76d1bb 100644 --- a/packages/utils/src/store/ReactStore.test.tsx +++ b/packages/utils/src/store/ReactStore.test.tsx @@ -1,13 +1,20 @@ import * as React from 'react'; import { expect } from 'chai'; -import { act, createRenderer } from '@mui/internal-test-utils'; +import { act, createRenderer, screen } from '@mui/internal-test-utils'; import { ReactStore } from './ReactStore'; import { useRefWithInit } from '../useRefWithInit'; type TestState = { value: number; label: string }; -function useStableStore(initial: State) { - return useRefWithInit(() => new ReactStore(initial)).current; +function useStableStore( + initial: State, + writeInterceptors?: Partial<{ + [Key in keyof State]: (value: State[Key]) => State[Key]; + }>, +) { + return useRefWithInit( + () => new ReactStore(initial, undefined, undefined, writeInterceptors), + ).current; } describe('ReactStore', () => { @@ -227,4 +234,297 @@ describe('ReactStore', () => { expect(store.state.element).to.equal(null); }); + + it('calls custom write interceptors when setting values with `set()`', () => { + type CustomState = { count: number; text: string }; + let store!: ReactStore; + + function Test() { + store = useStableStore( + { count: 10, text: '' }, + { + count: (value) => Math.max(0, value), + text: (value) => value.trim(), + }, + ); + + return null; + } + + render(); + + act(() => { + store.set('count', -5); + }); + + expect(store.state.count).to.equal(0); + + act(() => { + store.set('text', ' hello world '); + }); + + expect(store.state.text).to.equal('hello world'); + }); + + it('calls custom write interceptors when setting values with `update()`', () => { + type CustomState = { count: number; text: string }; + let store!: ReactStore; + + function Test() { + store = useStableStore( + { count: 10, text: '' }, + { + count: (value) => Math.max(0, value), + text: (value) => value.trim(), + }, + ); + + return null; + } + + render(); + + act(() => { + store.update({ + count: -10, + text: ' hello world ', + }); + }); + + expect(store.state.count).to.equal(0); + expect(store.state.text).to.equal('hello world'); + }); + + it('calls custom write interceptors when setting values with `setState()`', () => { + type CustomState = { count: number; text: string }; + let store!: ReactStore; + + function Test() { + store = useStableStore( + { count: 10, text: '' }, + { + count: (value) => Math.max(0, value), + text: (value) => value.trim(), + }, + ); + + return null; + } + + render(); + + act(() => { + store.setState({ + count: -10, + text: ' hello world ', + }); + }); + + expect(store.state.count).to.equal(0); + expect(store.state.text).to.equal('hello world'); + }); + + it('calls custom write interceptors but disacrds the result when setting values with `set()` on a controlled prop', () => { + type CustomState = { count: number; text: string }; + let store!: ReactStore; + + const log: string[] = []; + + function Test() { + store = useStableStore( + { count: 10, text: '' }, + { + count: (value) => { + log.push(`setting count: ${value}`); + return Math.max(0, value); + }, + text: (value) => { + log.push(`setting text: ${value}`); + return value.trim(); + }, + }, + ); + + store.useControlledProp('count', 10, 10); + store.useControlledProp('text', '', ''); + + return null; + } + + render(); + + act(() => { + store.set('count', -5); + }); + + expect(store.state.count).to.equal(10); + + act(() => { + store.set('text', ' hello world '); + }); + + expect(store.state.text).to.equal(''); + + expect(log).to.deep.equal(['setting count: -5', 'setting text: hello world ']); + }); + + it('calls custom write interceptors but disacrds the result when setting values with `update()` on a controlled prop', () => { + type CustomState = { count: number; text: string }; + let store!: ReactStore; + + const log: string[] = []; + + function Test() { + store = useStableStore( + { count: 10, text: '' }, + { + count: (value) => { + log.push(`setting count: ${value}`); + return Math.max(0, value); + }, + text: (value) => { + log.push(`setting text: ${value}`); + return value.trim(); + }, + }, + ); + + store.useControlledProp('count', 10, 10); + store.useControlledProp('text', '', ''); + + return null; + } + + render(); + + act(() => { + store.update({ count: -5, text: ' hello world ' }); + }); + + expect(store.state.count).to.equal(10); + expect(store.state.text).to.equal(''); + + expect(log).to.deep.equal(['setting count: -5', 'setting text: hello world ']); + }); + + it('calls custom write interceptors but disacrds the result when setting values with `setState()` on a controlled prop', () => { + type CustomState = { count: number; text: string }; + let store!: ReactStore; + + const log: string[] = []; + + function Test() { + store = useStableStore( + { count: 10, text: '' }, + { + count: (value) => { + log.push(`setting count: ${value}`); + return Math.max(0, value); + }, + text: (value) => { + log.push(`setting text: ${value}`); + return value.trim(); + }, + }, + ); + + store.useControlledProp('count', 10, 10); + store.useControlledProp('text', '', ''); + + return null; + } + + render(); + + act(() => { + store.setState({ count: -5, text: ' hello world ' }); + }); + + expect(store.state.count).to.equal(10); + expect(store.state.text).to.equal(''); + + expect(log).to.deep.equal(['setting count: -5', 'setting text: hello world ']); + }); + + it('supports nested stores as state values', async () => { + type ParentState = { count: number }; + type ChildState = { count: number; parent?: ReactStore }; + + const parentSelectors = { count: (state: ParentState) => state.count }; + const childSelectors = { + count: (state: ChildState) => state.parent?.state.count ?? state.count, + }; + + const childWriteInterceptors = { + count: (value: number, store: ReactStore) => { + if (store.state.parent) { + store.state.parent.set('count', value); + } + + return value; + }, + parent: ( + value: ReactStore | undefined, + store: ReactStore, + ) => { + if (value) { + value.subscribe(() => { + store.notifyAll(); + }); + } + + return value; + }, + }; + + const parentStore = new ReactStore, typeof parentSelectors>( + { count: 0 }, + undefined, + parentSelectors, + undefined, + ); + const childStore = new ReactStore, typeof childSelectors>( + { count: 10 }, + undefined, + childSelectors, + childWriteInterceptors, + ); + + function Test() { + const count = childStore.useState('count'); + return {count}; + } + + render(); + const output = screen.getByTestId('output'); + + await act(async () => { + childStore.set('count', 5); + }); + expect(childStore.state.count).to.equal(5); + expect(output.textContent).to.equal('5'); + + await act(async () => { + childStore.set('parent', parentStore); + }); + expect(childStore.state.count).to.equal(5); + expect(childStore.select('count')).to.equal(0); + expect(output.textContent).to.equal('0'); + + await act(async () => { + childStore.set('count', 20); + }); + expect(childStore.state.count).to.equal(20); + expect(parentStore.state.count).to.equal(20); + expect(childStore.select('count')).to.equal(20); + expect(output.textContent).to.equal('20'); + + await act(async () => { + parentStore.set('count', 15); + }); + expect(parentStore.state.count).to.equal(15); + expect(childStore.state.count).to.equal(20); + expect(childStore.select('count')).to.equal(15); + expect(output.textContent).to.equal('15'); + }); }); diff --git a/packages/utils/src/store/ReactStore.ts b/packages/utils/src/store/ReactStore.ts index 007476eb730..d61ec595016 100644 --- a/packages/utils/src/store/ReactStore.ts +++ b/packages/utils/src/store/ReactStore.ts @@ -11,14 +11,38 @@ import { NOOP } from '../empty'; * A Store that supports controlled state keys, non-reactive values and provides utility methods for React. */ export class ReactStore< - State, + State extends object, Context = Record, - Selectors extends Record any> = Record, + Selectors extends Record> = Record, > extends Store { - constructor(state: State, context: Context = {} as Context, selectors?: Selectors) { + /** + * Creates a new ReactStore instance. + * + * @param state Initial state of the store. + * @param context Non-reactive context values. + * @param selectors Optional selectors for use with `useState`. + * @param writeInterceptors Optional custom write interceptors for specific state keys. If provided, + * these functions are called whenever the corresponding key is updated via `set`, `apply`, or `update`. + * Note that updates to controlled keys are ignored regardless of write interceptors + * (though interceptors are still called, which may be useful for side effects). + */ + constructor( + state: State, + context: Context = {} as Context, + selectors?: Selectors, + writeInterceptors?: Partial<{ + [K in keyof State]: ( + value: State[K], + store: ReactStore, + ) => State[K]; + }>, + ) { super(state); this.context = context; this.selectors = selectors; + this.writeInterceptors = writeInterceptors + ? new Map(Object.entries(writeInterceptors) as any) + : undefined; } /** @@ -33,6 +57,8 @@ export class ReactStore< private selectors: Selectors | undefined; + private writeInterceptors: Map any> | undefined; + /** * Synchronizes a single external value into the store. * @@ -126,6 +152,18 @@ export class ReactStore< }, [key, controlled, defaultValue, isControlled]); } + /** Gets the current value from the store using a selector with the provided key. */ + public select( + key: Key, + ...args: SelectorArgs + ): ReturnType { + if (!this.selectors || !this.selectors[key]) { + throw new Error(`Base UI: Selector for key "${String(key)}" is not defined.`); + } + + return this.selectors[key](this.state, ...args); + } + /** * Sets a specific key in the store's state to a new value and notifies listeners if the value has changed. * If the key is controlled (registered via {@link useControlledProp} with a non-undefined value), @@ -135,6 +173,16 @@ export class ReactStore< * @param value The new value to set for the specified key. */ public set(key: keyof State, value: T): void { + const interceptor = this.writeInterceptors?.get(key); + if (interceptor) { + const updatedValue = interceptor(value, this); + if (!this.controlledValues.get(key) === true) { + super.set(key, updatedValue); + } + + return; + } + if (this.controlledValues.get(key) === true) { // Ignore updates to controlled values return; @@ -152,9 +200,20 @@ export class ReactStore< public update(values: Partial): void { const newValues = { ...values }; for (const key in newValues) { + if (!Object.hasOwn(newValues, key)) { + continue; + } + + const interceptor = this.writeInterceptors?.get(key); + if (interceptor) { + const updatedValue = interceptor(newValues[key as keyof State], this); + newValues[key as keyof State] = updatedValue; + } + if (this.controlledValues.get(key) === true) { // Ignore updates to controlled values delete newValues[key]; + continue; } } @@ -170,9 +229,20 @@ export class ReactStore< public setState(newState: State) { const newValues = { ...newState }; for (const key in newValues) { + if (!Object.hasOwn(newValues, key)) { + continue; + } + + const interceptor = this.writeInterceptors?.get(key); + if (interceptor) { + const updatedValue = interceptor(newValues[key as keyof State], this); + newValues[key as keyof State] = updatedValue; + } + if (this.controlledValues.get(key) === true) { // Ignore updates to controlled values delete newValues[key]; + continue; } } @@ -186,14 +256,21 @@ export class ReactStore< * * @param key Key of the selector to use. */ - public useState(key: Key): ReturnType { + public useState( + key: Key, + ...args: SelectorArgs + ): ReturnType { if (!this.selectors) { throw new Error('Base UI: selectors are required to call useState.'); } - return useStore>( - this, - this.selectors[key] as (state: State) => ReturnType, - ); + const selector = this.selectors[key]; + if (!selector) { + throw new Error(`Base UI: Selector for key "${String(key)}" is not defined.`); + } + + // Cast avoids emitting runtime branches and sidesteps TypeScript's overload checks, + // which do not accept spreading a generic tuple into `useStore`. + return (useStore as any)(this, selector, ...args) as ReturnType; } /** @@ -237,3 +314,11 @@ type ContextFunction = Extract = { [Key in keyof State]-?: undefined extends State[Key] ? Key : never; }[keyof State]; + +type SelectorFunction = (state: State, ...args: any[]) => any; + +type Tail = T extends readonly [any, ...infer Rest] ? Rest : []; + +type SelectorArgs = Selector extends (...params: infer Params) => any + ? Tail + : never; diff --git a/packages/utils/src/store/Store.ts b/packages/utils/src/store/Store.ts index 11f3d80939f..a01bb69047d 100644 --- a/packages/utils/src/store/Store.ts +++ b/packages/utils/src/store/Store.ts @@ -78,7 +78,7 @@ export class Store { public update(changes: Partial) { for (const key in changes) { if (!Object.is(this.state[key], changes[key])) { - this.setState({ ...this.state, ...changes }); + Store.prototype.setState.call(this, { ...this.state, ...changes }); return; } } @@ -92,7 +92,15 @@ export class Store { */ public set(key: keyof State, value: T) { if (!Object.is(this.state[key], value)) { - this.setState({ ...this.state, [key]: value }); + Store.prototype.setState.call(this, { ...this.state, [key]: value }); } } + + /** + * Gives the state a new reference and updates all registered listeners. + */ + public notifyAll() { + const newState = { ...this.state }; + Store.prototype.setState.call(this, newState); + } } diff --git a/packages/utils/tsconfig.build.json b/packages/utils/tsconfig.build.json index 9b3b741e0bd..3651896426a 100644 --- a/packages/utils/tsconfig.build.json +++ b/packages/utils/tsconfig.build.json @@ -10,7 +10,8 @@ "moduleResolution": "bundler", "noEmit": false, "rootDir": "./src", - "outDir": "build/esm" + "outDir": "build/esm", + "lib": ["ES2022", "DOM"] }, "include": ["src/**/*.ts*", "src/**/*.tsx"], "exclude": ["src/**/*.spec.ts*", "src/**/*.test.ts*"] From 83ea72b941810dd483f375fa5fc1f26ac6445c8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dudak?= Date: Thu, 23 Oct 2025 18:52:11 +0200 Subject: [PATCH 02/12] Alternative implementation --- packages/utils/src/store/ReactStore.test.tsx | 280 +++---------------- packages/utils/src/store/ReactStore.ts | 73 ++--- packages/utils/src/store/Store.ts | 8 - 3 files changed, 64 insertions(+), 297 deletions(-) diff --git a/packages/utils/src/store/ReactStore.test.tsx b/packages/utils/src/store/ReactStore.test.tsx index 3242b76d1bb..017da4ab407 100644 --- a/packages/utils/src/store/ReactStore.test.tsx +++ b/packages/utils/src/store/ReactStore.test.tsx @@ -6,15 +6,8 @@ import { useRefWithInit } from '../useRefWithInit'; type TestState = { value: number; label: string }; -function useStableStore( - initial: State, - writeInterceptors?: Partial<{ - [Key in keyof State]: (value: State[Key]) => State[Key]; - }>, -) { - return useRefWithInit( - () => new ReactStore(initial, undefined, undefined, writeInterceptors), - ).current; +function useStableStore(initial: State) { + return useRefWithInit(() => new ReactStore(initial)).current; } describe('ReactStore', () => { @@ -235,261 +228,54 @@ describe('ReactStore', () => { expect(store.state.element).to.equal(null); }); - it('calls custom write interceptors when setting values with `set()`', () => { - type CustomState = { count: number; text: string }; - let store!: ReactStore; - - function Test() { - store = useStableStore( - { count: 10, text: '' }, - { - count: (value) => Math.max(0, value), - text: (value) => value.trim(), - }, - ); - - return null; - } - - render(); - - act(() => { - store.set('count', -5); - }); - - expect(store.state.count).to.equal(0); - - act(() => { - store.set('text', ' hello world '); - }); - - expect(store.state.text).to.equal('hello world'); - }); - - it('calls custom write interceptors when setting values with `update()`', () => { - type CustomState = { count: number; text: string }; - let store!: ReactStore; - - function Test() { - store = useStableStore( - { count: 10, text: '' }, - { - count: (value) => Math.max(0, value), - text: (value) => value.trim(), - }, - ); - - return null; - } - - render(); - - act(() => { - store.update({ - count: -10, - text: ' hello world ', - }); - }); - - expect(store.state.count).to.equal(0); - expect(store.state.text).to.equal('hello world'); - }); - - it('calls custom write interceptors when setting values with `setState()`', () => { - type CustomState = { count: number; text: string }; - let store!: ReactStore; - - function Test() { - store = useStableStore( - { count: 10, text: '' }, - { - count: (value) => Math.max(0, value), - text: (value) => value.trim(), - }, - ); - - return null; - } - - render(); - - act(() => { - store.setState({ - count: -10, - text: ' hello world ', - }); - }); - - expect(store.state.count).to.equal(0); - expect(store.state.text).to.equal('hello world'); - }); - - it('calls custom write interceptors but disacrds the result when setting values with `set()` on a controlled prop', () => { - type CustomState = { count: number; text: string }; - let store!: ReactStore; - - const log: string[] = []; - - function Test() { - store = useStableStore( - { count: 10, text: '' }, - { - count: (value) => { - log.push(`setting count: ${value}`); - return Math.max(0, value); - }, - text: (value) => { - log.push(`setting text: ${value}`); - return value.trim(); - }, - }, - ); - - store.useControlledProp('count', 10, 10); - store.useControlledProp('text', '', ''); - - return null; - } - - render(); - - act(() => { - store.set('count', -5); - }); - - expect(store.state.count).to.equal(10); - - act(() => { - store.set('text', ' hello world '); - }); - - expect(store.state.text).to.equal(''); - - expect(log).to.deep.equal(['setting count: -5', 'setting text: hello world ']); - }); - - it('calls custom write interceptors but disacrds the result when setting values with `update()` on a controlled prop', () => { - type CustomState = { count: number; text: string }; - let store!: ReactStore; - - const log: string[] = []; - - function Test() { - store = useStableStore( - { count: 10, text: '' }, - { - count: (value) => { - log.push(`setting count: ${value}`); - return Math.max(0, value); - }, - text: (value) => { - log.push(`setting text: ${value}`); - return value.trim(); - }, - }, - ); - - store.useControlledProp('count', 10, 10); - store.useControlledProp('text', '', ''); - - return null; - } - - render(); - - act(() => { - store.update({ count: -5, text: ' hello world ' }); - }); - - expect(store.state.count).to.equal(10); - expect(store.state.text).to.equal(''); - - expect(log).to.deep.equal(['setting count: -5', 'setting text: hello world ']); - }); - - it('calls custom write interceptors but disacrds the result when setting values with `setState()` on a controlled prop', () => { - type CustomState = { count: number; text: string }; - let store!: ReactStore; - - const log: string[] = []; - - function Test() { - store = useStableStore( - { count: 10, text: '' }, - { - count: (value) => { - log.push(`setting count: ${value}`); - return Math.max(0, value); - }, - text: (value) => { - log.push(`setting text: ${value}`); - return value.trim(); - }, - }, - ); - - store.useControlledProp('count', 10, 10); - store.useControlledProp('text', '', ''); - - return null; - } - - render(); - - act(() => { - store.setState({ count: -5, text: ' hello world ' }); - }); - - expect(store.state.count).to.equal(10); - expect(store.state.text).to.equal(''); - - expect(log).to.deep.equal(['setting count: -5', 'setting text: hello world ']); - }); - it('supports nested stores as state values', async () => { type ParentState = { count: number }; type ChildState = { count: number; parent?: ReactStore }; const parentSelectors = { count: (state: ParentState) => state.count }; const childSelectors = { - count: (state: ChildState) => state.parent?.state.count ?? state.count, - }; - - const childWriteInterceptors = { - count: (value: number, store: ReactStore) => { - if (store.state.parent) { - store.state.parent.set('count', value); - } - - return value; - }, - parent: ( - value: ReactStore | undefined, - store: ReactStore, - ) => { - if (value) { - value.subscribe(() => { - store.notifyAll(); - }); - } - - return value; - }, + count: (state: ChildState) => state.count, + parent: (state: ChildState) => state.parent, }; const parentStore = new ReactStore, typeof parentSelectors>( { count: 0 }, undefined, parentSelectors, - undefined, ); + const childStore = new ReactStore, typeof childSelectors>( { count: 10 }, undefined, childSelectors, - childWriteInterceptors, ); + let unsubscribeParentHandler: () => void; + const onParentUpdated = ( + newParent: ReactStore | undefined, + _: ReactStore | undefined, + store: ReactStore, + ) => { + if (!newParent) { + unsubscribeParentHandler?.(); + return; + } + unsubscribeParentHandler = newParent.observe('count', (newCount) => { + store.set('count', newCount); + }); + }; + + const onCountUpdated = ( + newCount: number, + _: number, + store: ReactStore, + ) => { + store.state.parent?.set('count', newCount); + }; + + childStore.observe('parent', onParentUpdated); + childStore.observe('count', onCountUpdated); + function Test() { const count = childStore.useState('count'); return {count}; @@ -507,7 +293,7 @@ describe('ReactStore', () => { await act(async () => { childStore.set('parent', parentStore); }); - expect(childStore.state.count).to.equal(5); + expect(childStore.state.count).to.equal(0); expect(childStore.select('count')).to.equal(0); expect(output.textContent).to.equal('0'); @@ -523,7 +309,7 @@ describe('ReactStore', () => { parentStore.set('count', 15); }); expect(parentStore.state.count).to.equal(15); - expect(childStore.state.count).to.equal(20); + expect(childStore.state.count).to.equal(15); expect(childStore.select('count')).to.equal(15); expect(output.textContent).to.equal('15'); }); diff --git a/packages/utils/src/store/ReactStore.ts b/packages/utils/src/store/ReactStore.ts index d61ec595016..535f5862372 100644 --- a/packages/utils/src/store/ReactStore.ts +++ b/packages/utils/src/store/ReactStore.ts @@ -21,28 +21,11 @@ export class ReactStore< * @param state Initial state of the store. * @param context Non-reactive context values. * @param selectors Optional selectors for use with `useState`. - * @param writeInterceptors Optional custom write interceptors for specific state keys. If provided, - * these functions are called whenever the corresponding key is updated via `set`, `apply`, or `update`. - * Note that updates to controlled keys are ignored regardless of write interceptors - * (though interceptors are still called, which may be useful for side effects). */ - constructor( - state: State, - context: Context = {} as Context, - selectors?: Selectors, - writeInterceptors?: Partial<{ - [K in keyof State]: ( - value: State[K], - store: ReactStore, - ) => State[K]; - }>, - ) { + constructor(state: State, context: Context = {} as Context, selectors?: Selectors) { super(state); this.context = context; this.selectors = selectors; - this.writeInterceptors = writeInterceptors - ? new Map(Object.entries(writeInterceptors) as any) - : undefined; } /** @@ -57,8 +40,6 @@ export class ReactStore< private selectors: Selectors | undefined; - private writeInterceptors: Map any> | undefined; - /** * Synchronizes a single external value into the store. * @@ -173,16 +154,6 @@ export class ReactStore< * @param value The new value to set for the specified key. */ public set(key: keyof State, value: T): void { - const interceptor = this.writeInterceptors?.get(key); - if (interceptor) { - const updatedValue = interceptor(value, this); - if (!this.controlledValues.get(key) === true) { - super.set(key, updatedValue); - } - - return; - } - if (this.controlledValues.get(key) === true) { // Ignore updates to controlled values return; @@ -204,12 +175,6 @@ export class ReactStore< continue; } - const interceptor = this.writeInterceptors?.get(key); - if (interceptor) { - const updatedValue = interceptor(newValues[key as keyof State], this); - newValues[key as keyof State] = updatedValue; - } - if (this.controlledValues.get(key) === true) { // Ignore updates to controlled values delete newValues[key]; @@ -233,12 +198,6 @@ export class ReactStore< continue; } - const interceptor = this.writeInterceptors?.get(key); - if (interceptor) { - const updatedValue = interceptor(newValues[key as keyof State], this); - newValues[key as keyof State] = updatedValue; - } - if (this.controlledValues.get(key) === true) { // Ignore updates to controlled values delete newValues[key]; @@ -301,6 +260,36 @@ export class ReactStore< [key], ); } + + /** + * Observes changes derived from the store's selectors and calls the listener when the selected value changes. + */ + public observe( + selectorKey: Key, + listener: ( + newValue: ReturnType, + oldValue: ReturnType, + store: this, + ) => void, + ) { + if (!this.selectors || !Object.hasOwn(this.selectors, selectorKey)) { + throw new Error(`Base UI: Selector for key "${String(selectorKey)}" is not defined.`); + } + + const selector = this.selectors[selectorKey]; + let prevValue = selector(this.state); + + listener(prevValue, prevValue, this); + + return this.subscribe((nextState) => { + const nextValue = selector(nextState); + if (!Object.is(prevValue, nextValue)) { + const oldValue = prevValue; + prevValue = nextValue; + listener(nextValue, oldValue, this); + } + }); + } } type MaybeCallable = (...args: any[]) => any; diff --git a/packages/utils/src/store/Store.ts b/packages/utils/src/store/Store.ts index a01bb69047d..68761c07f9d 100644 --- a/packages/utils/src/store/Store.ts +++ b/packages/utils/src/store/Store.ts @@ -95,12 +95,4 @@ export class Store { Store.prototype.setState.call(this, { ...this.state, [key]: value }); } } - - /** - * Gives the state a new reference and updates all registered listeners. - */ - public notifyAll() { - const newState = { ...this.state }; - Store.prototype.setState.call(this, newState); - } } From cbbd9bbb4786e74ff85750b582ab75927dda68df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dudak?= Date: Thu, 23 Oct 2025 19:08:18 +0200 Subject: [PATCH 03/12] Alternative implementation - subscribe to state field updates --- packages/utils/src/store/ReactStore.test.tsx | 11 ++++++----- packages/utils/src/store/ReactStore.ts | 19 +++++-------------- packages/utils/src/store/Store.ts | 8 ++++++++ 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/packages/utils/src/store/ReactStore.test.tsx b/packages/utils/src/store/ReactStore.test.tsx index 017da4ab407..f02325d29b6 100644 --- a/packages/utils/src/store/ReactStore.test.tsx +++ b/packages/utils/src/store/ReactStore.test.tsx @@ -234,7 +234,7 @@ describe('ReactStore', () => { const parentSelectors = { count: (state: ParentState) => state.count }; const childSelectors = { - count: (state: ChildState) => state.count, + count: (state: ChildState) => state.parent?.state.count ?? state.count, parent: (state: ChildState) => state.parent, }; @@ -260,8 +260,9 @@ describe('ReactStore', () => { unsubscribeParentHandler?.(); return; } - unsubscribeParentHandler = newParent.observe('count', (newCount) => { - store.set('count', newCount); + + unsubscribeParentHandler = newParent.subscribe(() => { + store.notifyAll(); }); }; @@ -293,7 +294,7 @@ describe('ReactStore', () => { await act(async () => { childStore.set('parent', parentStore); }); - expect(childStore.state.count).to.equal(0); + expect(childStore.state.count).to.equal(5); expect(childStore.select('count')).to.equal(0); expect(output.textContent).to.equal('0'); @@ -309,7 +310,7 @@ describe('ReactStore', () => { parentStore.set('count', 15); }); expect(parentStore.state.count).to.equal(15); - expect(childStore.state.count).to.equal(15); + expect(childStore.state.count).to.equal(20); expect(childStore.select('count')).to.equal(15); expect(output.textContent).to.equal('15'); }); diff --git a/packages/utils/src/store/ReactStore.ts b/packages/utils/src/store/ReactStore.ts index 535f5862372..1556e3dd2f2 100644 --- a/packages/utils/src/store/ReactStore.ts +++ b/packages/utils/src/store/ReactStore.ts @@ -264,25 +264,16 @@ export class ReactStore< /** * Observes changes derived from the store's selectors and calls the listener when the selected value changes. */ - public observe( - selectorKey: Key, - listener: ( - newValue: ReturnType, - oldValue: ReturnType, - store: this, - ) => void, + public observe( + key: Key, + listener: (newValue: State[Key], oldValue: State[Key], store: this) => void, ) { - if (!this.selectors || !Object.hasOwn(this.selectors, selectorKey)) { - throw new Error(`Base UI: Selector for key "${String(selectorKey)}" is not defined.`); - } - - const selector = this.selectors[selectorKey]; - let prevValue = selector(this.state); + let prevValue = this.state[key]; listener(prevValue, prevValue, this); return this.subscribe((nextState) => { - const nextValue = selector(nextState); + const nextValue = nextState[key]; if (!Object.is(prevValue, nextValue)) { const oldValue = prevValue; prevValue = nextValue; diff --git a/packages/utils/src/store/Store.ts b/packages/utils/src/store/Store.ts index 68761c07f9d..a01bb69047d 100644 --- a/packages/utils/src/store/Store.ts +++ b/packages/utils/src/store/Store.ts @@ -95,4 +95,12 @@ export class Store { Store.prototype.setState.call(this, { ...this.state, [key]: value }); } } + + /** + * Gives the state a new reference and updates all registered listeners. + */ + public notifyAll() { + const newState = { ...this.state }; + Store.prototype.setState.call(this, newState); + } } From 38d205f5687e35b1a63e1f9970e157de28796080 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dudak?= Date: Thu, 30 Oct 2025 08:33:40 +0100 Subject: [PATCH 04/12] Add methods to observe raw state and selector results --- packages/utils/src/store/ReactStore.test.tsx | 343 ++++++++++++++++++- packages/utils/src/store/ReactStore.ts | 40 ++- 2 files changed, 378 insertions(+), 5 deletions(-) diff --git a/packages/utils/src/store/ReactStore.test.tsx b/packages/utils/src/store/ReactStore.test.tsx index f02325d29b6..15330574f3b 100644 --- a/packages/utils/src/store/ReactStore.test.tsx +++ b/packages/utils/src/store/ReactStore.test.tsx @@ -189,7 +189,7 @@ describe('ReactStore', () => { expect(store.state.node).to.equal(undefined); }); - it('getElementSetter returns a stable callback that updates the store state', () => { + it('useStateSetter returns a stable callback that updates the store state', () => { type ElementState = { element: HTMLDivElement | null }; let store!: ReactStore; let forceUpdate!: React.Dispatch>; @@ -274,8 +274,8 @@ describe('ReactStore', () => { store.state.parent?.set('count', newCount); }; - childStore.observe('parent', onParentUpdated); - childStore.observe('count', onCountUpdated); + childStore.observeState('parent', onParentUpdated); + childStore.observeState('count', onCountUpdated); function Test() { const count = childStore.useState('count'); @@ -314,4 +314,341 @@ describe('ReactStore', () => { expect(childStore.select('count')).to.equal(15); expect(output.textContent).to.equal('15'); }); + + describe('observeState', () => { + it('calls listener immediately with current value on subscription', () => { + const store = new ReactStore({ value: 5, label: 'initial' }); + const calls: Array<{ newValue: number; oldValue: number }> = []; + + store.observeState('value', (newValue, oldValue) => { + calls.push({ newValue, oldValue }); + }); + + expect(calls).to.have.lengthOf(1); + expect(calls[0]).to.deep.equal({ newValue: 5, oldValue: 5 }); + }); + + it('calls listener when observed state key changes', () => { + const store = new ReactStore({ value: 5, label: 'initial' }); + const calls: Array<{ newValue: number; oldValue: number }> = []; + + store.observeState('value', (newValue, oldValue) => { + calls.push({ newValue, oldValue }); + }); + + store.set('value', 10); + store.set('value', 15); + + expect(calls).to.have.lengthOf(3); + expect(calls[1]).to.deep.equal({ newValue: 10, oldValue: 5 }); + expect(calls[2]).to.deep.equal({ newValue: 15, oldValue: 10 }); + }); + + it('does not call listener when non-observed state keys change', () => { + const store = new ReactStore({ value: 5, label: 'initial' }); + const calls: Array<{ newValue: number; oldValue: number }> = []; + + store.observeState('value', (newValue, oldValue) => { + calls.push({ newValue, oldValue }); + }); + + store.set('label', 'updated'); + + expect(calls).to.have.lengthOf(1); // Only initial call + }); + + it('does not call listener when value is set to same value (Object.is comparison)', () => { + const store = new ReactStore({ value: 5, label: 'initial' }); + const calls: Array<{ newValue: number; oldValue: number }> = []; + + store.observeState('value', (newValue, oldValue) => { + calls.push({ newValue, oldValue }); + }); + + store.set('value', 5); + + expect(calls).to.have.lengthOf(1); // Only initial call + }); + + it('provides the store instance to the listener', () => { + const store = new ReactStore({ value: 5, label: 'initial' }); + let receivedStore!: ReactStore; + + store.observeState('value', (_, __, storeArg) => { + receivedStore = storeArg; + }); + + expect(receivedStore).to.equal(store); + }); + + it('returns an unsubscribe function that stops observing', () => { + const store = new ReactStore({ value: 5, label: 'initial' }); + const calls: Array<{ newValue: number; oldValue: number }> = []; + + const unsubscribe = store.observeState('value', (newValue, oldValue) => { + calls.push({ newValue, oldValue }); + }); + + store.set('value', 10); + expect(calls).to.have.lengthOf(2); + + unsubscribe(); + + store.set('value', 15); + expect(calls).to.have.lengthOf(2); // No new calls after unsubscribe + }); + + it('supports multiple observers on the same key', () => { + const store = new ReactStore({ value: 5, label: 'initial' }); + const calls1: number[] = []; + const calls2: number[] = []; + + store.observeState('value', (newValue) => { + calls1.push(newValue); + }); + + store.observeState('value', (newValue) => { + calls2.push(newValue); + }); + + store.set('value', 10); + + expect(calls1).to.deep.equal([5, 10]); + expect(calls2).to.deep.equal([5, 10]); + }); + + it('supports observers on different keys', () => { + const store = new ReactStore({ value: 5, label: 'initial' }); + const valueCalls: number[] = []; + const labelCalls: string[] = []; + + store.observeState('value', (newValue) => { + valueCalls.push(newValue); + }); + + store.observeState('label', (newValue) => { + labelCalls.push(newValue); + }); + + store.set('value', 10); + store.set('label', 'updated'); + + expect(valueCalls).to.deep.equal([5, 10]); + expect(labelCalls).to.deep.equal(['initial', 'updated']); + }); + + it('tracks changes made through update method', () => { + const store = new ReactStore({ value: 5, label: 'initial' }); + const calls: Array<{ newValue: number; oldValue: number }> = []; + + store.observeState('value', (newValue, oldValue) => { + calls.push({ newValue, oldValue }); + }); + + store.update({ value: 20, label: 'updated' }); + + expect(calls).to.have.lengthOf(2); + expect(calls[1]).to.deep.equal({ newValue: 20, oldValue: 5 }); + }); + + it('tracks changes made through setState method', () => { + const store = new ReactStore({ value: 5, label: 'initial' }); + const calls: Array<{ newValue: number; oldValue: number }> = []; + + store.observeState('value', (newValue, oldValue) => { + calls.push({ newValue, oldValue }); + }); + + store.setState({ value: 25, label: 'new' }); + + expect(calls).to.have.lengthOf(2); + expect(calls[1]).to.deep.equal({ newValue: 25, oldValue: 5 }); + }); + }); + + describe('observeSelector', () => { + type CounterState = { count: number; multiplier: number }; + const selectors = { + count: (state: CounterState) => state.count, + doubled: (state: CounterState) => state.count * 2, + multiplied: (state: CounterState) => state.count * state.multiplier, + }; + + it('calls listener immediately with current selector result on subscription', () => { + const store = new ReactStore, typeof selectors>( + { count: 5, multiplier: 3 }, + undefined, + selectors, + ); + const calls: Array<{ newValue: number; oldValue: number }> = []; + + store.observeSelector('doubled', (newValue: number, oldValue: number) => { + calls.push({ newValue, oldValue }); + }); + + expect(calls).to.have.lengthOf(1); + expect(calls[0]).to.deep.equal({ newValue: 10, oldValue: 10 }); + }); + + it('calls listener when selector result changes', () => { + const store = new ReactStore, typeof selectors>( + { count: 5, multiplier: 3 }, + undefined, + selectors, + ); + const calls: Array<{ newValue: number; oldValue: number }> = []; + + store.observeSelector('doubled', (newValue: number, oldValue: number) => { + calls.push({ newValue, oldValue }); + }); + + store.set('count', 10); + store.set('count', 7); + + expect(calls).to.have.lengthOf(3); + expect(calls[1]).to.deep.equal({ newValue: 20, oldValue: 10 }); + expect(calls[2]).to.deep.equal({ newValue: 14, oldValue: 20 }); + }); + + it('does not call listener when selector result is unchanged', () => { + const store = new ReactStore, typeof selectors>( + { count: 5, multiplier: 3 }, + undefined, + selectors, + ); + const calls: Array<{ newValue: number; oldValue: number }> = []; + + store.observeSelector('doubled', (newValue: number, oldValue: number) => { + calls.push({ newValue, oldValue }); + }); + + store.set('multiplier', 5); + + expect(calls).to.have.lengthOf(1); // Only initial call + }); + + it('calls listener when any dependency of the selector changes', () => { + const store = new ReactStore, typeof selectors>( + { count: 5, multiplier: 3 }, + undefined, + selectors, + ); + const calls: Array<{ newValue: number; oldValue: number }> = []; + + store.observeSelector('multiplied', (newValue: number, oldValue: number) => { + calls.push({ newValue, oldValue }); + }); + + store.set('count', 10); + store.set('multiplier', 2); + + expect(calls).to.have.lengthOf(3); + expect(calls[0]).to.deep.equal({ newValue: 15, oldValue: 15 }); + expect(calls[1]).to.deep.equal({ newValue: 30, oldValue: 15 }); + expect(calls[2]).to.deep.equal({ newValue: 20, oldValue: 30 }); + }); + + it('provides the store instance to the listener', () => { + const store = new ReactStore, typeof selectors>( + { count: 5, multiplier: 3 }, + undefined, + selectors, + ); + let receivedStore!: ReactStore, typeof selectors>; + + store.observeSelector('doubled', (_: number, __: number, storeArg) => { + receivedStore = storeArg; + }); + + expect(receivedStore).to.equal(store); + }); + + it('returns an unsubscribe function that stops observing', () => { + const store = new ReactStore, typeof selectors>( + { count: 5, multiplier: 3 }, + undefined, + selectors, + ); + const calls: Array<{ newValue: number; oldValue: number }> = []; + + const unsubscribe = store.observeSelector('doubled', (newValue: number, oldValue: number) => { + calls.push({ newValue, oldValue }); + }); + + store.set('count', 10); + expect(calls).to.have.lengthOf(2); + + unsubscribe(); + + store.set('count', 15); + expect(calls).to.have.lengthOf(2); // No new calls after unsubscribe + }); + + it('throws error when selector key does not exist', () => { + const store = new ReactStore, typeof selectors>( + { count: 5, multiplier: 3 }, + undefined, + selectors, + ); + + expect(() => { + store.observeSelector('nonexistent' as any, () => {}); + }).to.throw('Base UI: Selector for key "nonexistent" is not defined.'); + }); + + it('throws error when store has no selectors', () => { + const store = new ReactStore({ count: 5, multiplier: 3 }); + + expect(() => { + store.observeSelector('doubled' as any, () => {}); + }).to.throw('Base UI: Selector for key "doubled" is not defined.'); + }); + + it('supports multiple observers on the same selector', () => { + const store = new ReactStore, typeof selectors>( + { count: 5, multiplier: 3 }, + undefined, + selectors, + ); + const calls1: number[] = []; + const calls2: number[] = []; + + store.observeSelector('doubled', (newValue: number) => { + calls1.push(newValue); + }); + + store.observeSelector('doubled', (newValue: number) => { + calls2.push(newValue); + }); + + store.set('count', 10); + + expect(calls1).to.deep.equal([10, 20]); + expect(calls2).to.deep.equal([10, 20]); + }); + + it('supports observers on different selectors', () => { + const store = new ReactStore, typeof selectors>( + { count: 5, multiplier: 3 }, + undefined, + selectors, + ); + const doubledCalls: number[] = []; + const multipliedCalls: number[] = []; + + store.observeSelector('doubled', (newValue: number) => { + doubledCalls.push(newValue); + }); + + store.observeSelector('multiplied', (newValue: number) => { + multipliedCalls.push(newValue); + }); + + store.set('count', 10); + store.set('multiplier', 2); + + expect(doubledCalls).to.deep.equal([10, 20]); + expect(multipliedCalls).to.deep.equal([15, 30, 20]); + }); + }); }); diff --git a/packages/utils/src/store/ReactStore.ts b/packages/utils/src/store/ReactStore.ts index 1556e3dd2f2..252760b4dc0 100644 --- a/packages/utils/src/store/ReactStore.ts +++ b/packages/utils/src/store/ReactStore.ts @@ -250,6 +250,7 @@ export class ReactStore< /** * Returns a stable setter function for a specific key in the store's state. * It's commonly used to pass as a ref callback to React elements. + * * @param key Key of the state to set. */ public getElementSetter(key: keyof State) { @@ -262,9 +263,12 @@ export class ReactStore< } /** - * Observes changes derived from the store's selectors and calls the listener when the selected value changes. + * Observes changes in the state properties and calls the listener when the value changes. + * + * @param key Key of the state property to observe. + * @param listener Listener function called when the value changes. */ - public observe( + public observeState( key: Key, listener: (newValue: State[Key], oldValue: State[Key], store: this) => void, ) { @@ -281,6 +285,38 @@ export class ReactStore< } }); } + + /** + * Observes changes derived from the store's selectors and calls the listener when the selected value changes. + * + * @param key Key of the selector to observe. + * @param listener Listener function called when the selector result changes. + */ + public observeSelector( + key: Key, + listener: ( + newValue: ReturnType, + oldValue: ReturnType, + store: this, + ) => void, + ) { + if (!this.selectors || !Object.hasOwn(this.selectors, key)) { + throw new Error(`Base UI: Selector for key "${key as string}" is not defined.`); + } + + let prevValue = this.selectors[key]?.(this.state) as ReturnType; + + listener(prevValue, prevValue, this); + + return this.subscribe((nextState) => { + const nextValue = this.selectors![key](nextState); + if (!Object.is(prevValue, nextValue)) { + const oldValue = prevValue; + prevValue = nextValue; + listener(nextValue, oldValue, this); + } + }); + } } type MaybeCallable = (...args: any[]) => any; From df01d3f3953ccb9de05b344ad21e9fdc04c84241 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dudak?= Date: Thu, 30 Oct 2025 09:57:54 +0100 Subject: [PATCH 05/12] Rename getElementSetter -> useStateSetter --- packages/react/src/alert-dialog/popup/AlertDialogPopup.tsx | 2 +- .../react/src/alert-dialog/viewport/AlertDialogViewport.tsx | 2 +- packages/react/src/dialog/popup/DialogPopup.tsx | 2 +- packages/react/src/dialog/viewport/DialogViewport.tsx | 2 +- packages/react/src/tooltip/popup/TooltipPopup.tsx | 2 +- packages/react/src/tooltip/positioner/TooltipPositioner.tsx | 2 +- packages/utils/src/store/ReactStore.test.tsx | 2 +- packages/utils/src/store/ReactStore.ts | 6 +++--- 8 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/react/src/alert-dialog/popup/AlertDialogPopup.tsx b/packages/react/src/alert-dialog/popup/AlertDialogPopup.tsx index 79a967b496b..22a9fc48f37 100644 --- a/packages/react/src/alert-dialog/popup/AlertDialogPopup.tsx +++ b/packages/react/src/alert-dialog/popup/AlertDialogPopup.tsx @@ -104,7 +104,7 @@ export const AlertDialogPopup = React.forwardRef(function AlertDialogPopup( }, elementProps, ], - ref: [forwardedRef, store.context.popupRef, store.getElementSetter('popupElement')], + ref: [forwardedRef, store.context.popupRef, store.useStateSetter('popupElement')], stateAttributesMapping, }); diff --git a/packages/react/src/alert-dialog/viewport/AlertDialogViewport.tsx b/packages/react/src/alert-dialog/viewport/AlertDialogViewport.tsx index 74ba2216313..a56e4c8f699 100644 --- a/packages/react/src/alert-dialog/viewport/AlertDialogViewport.tsx +++ b/packages/react/src/alert-dialog/viewport/AlertDialogViewport.tsx @@ -59,7 +59,7 @@ export const AlertDialogViewport = React.forwardRef(function AlertDialogViewport return useRenderElement('div', componentProps, { enabled: shouldRender, state, - ref: [forwardedRef, store.getElementSetter('viewportElement')], + ref: [forwardedRef, store.useStateSetter('viewportElement')], stateAttributesMapping, props: [ { diff --git a/packages/react/src/dialog/popup/DialogPopup.tsx b/packages/react/src/dialog/popup/DialogPopup.tsx index cbc1d1475e3..8a3e459aff7 100644 --- a/packages/react/src/dialog/popup/DialogPopup.tsx +++ b/packages/react/src/dialog/popup/DialogPopup.tsx @@ -107,7 +107,7 @@ export const DialogPopup = React.forwardRef(function DialogPopup( }, elementProps, ], - ref: [forwardedRef, store.context.popupRef, store.getElementSetter('popupElement')], + ref: [forwardedRef, store.context.popupRef, store.useStateSetter('popupElement')], stateAttributesMapping, }); diff --git a/packages/react/src/dialog/viewport/DialogViewport.tsx b/packages/react/src/dialog/viewport/DialogViewport.tsx index 797e27456ab..ca9e32293aa 100644 --- a/packages/react/src/dialog/viewport/DialogViewport.tsx +++ b/packages/react/src/dialog/viewport/DialogViewport.tsx @@ -59,7 +59,7 @@ export const DialogViewport = React.forwardRef(function DialogViewport( return useRenderElement('div', componentProps, { enabled: shouldRender, state, - ref: [forwardedRef, store.getElementSetter('viewportElement')], + ref: [forwardedRef, store.useStateSetter('viewportElement')], stateAttributesMapping, props: [ { diff --git a/packages/react/src/tooltip/popup/TooltipPopup.tsx b/packages/react/src/tooltip/popup/TooltipPopup.tsx index c0eb7bab58b..91d63c5c950 100644 --- a/packages/react/src/tooltip/popup/TooltipPopup.tsx +++ b/packages/react/src/tooltip/popup/TooltipPopup.tsx @@ -95,7 +95,7 @@ export const TooltipPopup = React.forwardRef(function TooltipPopup( const element = useRenderElement('div', componentProps, { state, - ref: [forwardedRef, store.context.popupRef, store.getElementSetter('popupElement')], + ref: [forwardedRef, store.context.popupRef, store.useStateSetter('popupElement')], props: [ popupProps, transitionStatus === 'starting' ? DISABLED_TRANSITIONS_STYLE : EMPTY_OBJECT, diff --git a/packages/react/src/tooltip/positioner/TooltipPositioner.tsx b/packages/react/src/tooltip/positioner/TooltipPositioner.tsx index 17f4175a0e2..abae67637df 100644 --- a/packages/react/src/tooltip/positioner/TooltipPositioner.tsx +++ b/packages/react/src/tooltip/positioner/TooltipPositioner.tsx @@ -123,7 +123,7 @@ export const TooltipPositioner = React.forwardRef(function TooltipPositioner( const element = useRenderElement('div', componentProps, { state, props: [positioner.props, elementProps], - ref: [forwardedRef, store.getElementSetter('positionerElement')], + ref: [forwardedRef, store.useStateSetter('positionerElement')], stateAttributesMapping: popupStateMapping, }); diff --git a/packages/utils/src/store/ReactStore.test.tsx b/packages/utils/src/store/ReactStore.test.tsx index 15330574f3b..79ccf64bac3 100644 --- a/packages/utils/src/store/ReactStore.test.tsx +++ b/packages/utils/src/store/ReactStore.test.tsx @@ -199,7 +199,7 @@ describe('ReactStore', () => { function Test() { store = useStableStore({ element: null }); - const setter = store.getElementSetter('element'); + const setter = store.useStateSetter('element'); lastSetter = setter; const [, setTick] = React.useState(0); forceUpdate = setTick; diff --git a/packages/utils/src/store/ReactStore.ts b/packages/utils/src/store/ReactStore.ts index 252760b4dc0..eb2da343e83 100644 --- a/packages/utils/src/store/ReactStore.ts +++ b/packages/utils/src/store/ReactStore.ts @@ -253,10 +253,10 @@ export class ReactStore< * * @param key Key of the state to set. */ - public getElementSetter(key: keyof State) { + public useStateSetter(key: keyof State) { return React.useCallback( - (element: Value) => { - this.set(key, element); + (value: Value) => { + this.set(key, value); }, [key], ); From 5ddba7c6965f17514677baa882cd121500146bc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dudak?= Date: Thu, 30 Oct 2025 12:08:45 +0100 Subject: [PATCH 06/12] Do not spread parameters in useState/select --- packages/react/test/index.ts | 2 +- packages/utils/src/store/ReactStore.spec.ts | 49 ++++++++++++++ packages/utils/src/store/ReactStore.ts | 67 ++++++++++++++----- .../test/utils.ts => utils/src/testUtils.ts} | 0 4 files changed, 99 insertions(+), 19 deletions(-) create mode 100644 packages/utils/src/store/ReactStore.spec.ts rename packages/{react/test/utils.ts => utils/src/testUtils.ts} (100%) diff --git a/packages/react/test/index.ts b/packages/react/test/index.ts index a4798274ab4..d9f66a27903 100644 --- a/packages/react/test/index.ts +++ b/packages/react/test/index.ts @@ -1,4 +1,4 @@ +export * from '@base-ui-components/utils/testUtils'; export { createRenderer } from './createRenderer'; export { describeConformance } from './describeConformance'; export { popupConformanceTests } from './popupConformanceTests'; -export * from './utils'; diff --git a/packages/utils/src/store/ReactStore.spec.ts b/packages/utils/src/store/ReactStore.spec.ts new file mode 100644 index 00000000000..6c1fc1e0611 --- /dev/null +++ b/packages/utils/src/store/ReactStore.spec.ts @@ -0,0 +1,49 @@ +import { expectType } from '../testUtils'; +import { createSelector } from './createSelector'; +import { ReactStore } from './ReactStore'; + +interface TestState { + count: number | undefined; + text: string; +} + +const selectors = { + count: createSelector((state: TestState) => state.count), + text: createSelector((state: TestState) => state.text), + textLongerThan(state: TestState, length: number) { + return state.text.length > length; + }, + textLengthBetween(state: TestState, minLength: number, maxLength: number) { + return state.text.length >= minLength && state.text.length <= maxLength; + }, +}; + +const store = new ReactStore, typeof selectors>( + { count: 0, text: '' }, + undefined, + selectors, +); + +const count = store.select('count'); +expectType(count); + +const text = store.select('text'); +expectType(text); + +const isTextLongerThan5 = store.select('textLongerThan', 5); +expectType(isTextLongerThan5); + +const isTextLengthBetween3And10 = store.select('textLengthBetween', 3, 10); +expectType(isTextLengthBetween3And10); + +const countReactive = store.useState('count'); +expectType(countReactive); + +const textReactive = store.useState('text'); +expectType(textReactive); + +const isTextLongerThan7Reactive = store.useState('textLongerThan', 7); +expectType(isTextLongerThan7Reactive); + +const isTextLengthBetween2And8Reactive = store.useState('textLengthBetween', 2, 8); +expectType(isTextLengthBetween2And8Reactive); diff --git a/packages/utils/src/store/ReactStore.ts b/packages/utils/src/store/ReactStore.ts index eb2da343e83..b09e01b0222 100644 --- a/packages/utils/src/store/ReactStore.ts +++ b/packages/utils/src/store/ReactStore.ts @@ -134,15 +134,33 @@ export class ReactStore< } /** Gets the current value from the store using a selector with the provided key. */ + public select(key: Key): ReturnType; + public select( key: Key, - ...args: SelectorArgs - ): ReturnType { - if (!this.selectors || !this.selectors[key]) { - throw new Error(`Base UI: Selector for key "${String(key)}" is not defined.`); - } + a1: SelectorArgs[0], + ): ReturnType; - return this.selectors[key](this.state, ...args); + public select( + key: Key, + a1: SelectorArgs[0], + a2: SelectorArgs[1], + ): ReturnType; + + public select( + key: Key, + a1: SelectorArgs[0], + a2: SelectorArgs[1], + a3: SelectorArgs[2], + ): ReturnType; + + public select( + key: Key, + a1?: SelectorArgs[0], + a2?: SelectorArgs[1], + a3?: SelectorArgs[2], + ): ReturnType { + return this.selectors![key](this.state, a1, a2, a3); } /** @@ -215,21 +233,34 @@ export class ReactStore< * * @param key Key of the selector to use. */ + public useState(key: Key): ReturnType; + public useState( key: Key, - ...args: SelectorArgs - ): ReturnType { - if (!this.selectors) { - throw new Error('Base UI: selectors are required to call useState.'); - } - const selector = this.selectors[key]; - if (!selector) { - throw new Error(`Base UI: Selector for key "${String(key)}" is not defined.`); - } + a1: SelectorArgs[0], + ): ReturnType; + + public useState( + key: Key, + a1: SelectorArgs[0], + a2: SelectorArgs[1], + ): ReturnType; - // Cast avoids emitting runtime branches and sidesteps TypeScript's overload checks, - // which do not accept spreading a generic tuple into `useStore`. - return (useStore as any)(this, selector, ...args) as ReturnType; + public useState( + key: Key, + a1: SelectorArgs[0], + a2: SelectorArgs[1], + a3: SelectorArgs[2], + ): ReturnType; + + public useState( + key: Key, + a1?: SelectorArgs[0], + a2?: SelectorArgs[1], + a3?: SelectorArgs[2], + ): ReturnType { + const selector = this.selectors![key]; + return useStore(this, selector, a1, a2, a3); } /** diff --git a/packages/react/test/utils.ts b/packages/utils/src/testUtils.ts similarity index 100% rename from packages/react/test/utils.ts rename to packages/utils/src/testUtils.ts From 4c522c1b2811b809f27f5b8add1fb6faaae86d2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dudak?= Date: Thu, 30 Oct 2025 13:19:05 +0100 Subject: [PATCH 07/12] Improve types --- packages/utils/src/store/ReactStore.spec.ts | 24 +++++++ packages/utils/src/store/ReactStore.ts | 75 +++++---------------- 2 files changed, 42 insertions(+), 57 deletions(-) diff --git a/packages/utils/src/store/ReactStore.spec.ts b/packages/utils/src/store/ReactStore.spec.ts index 6c1fc1e0611..8351655c4ef 100644 --- a/packages/utils/src/store/ReactStore.spec.ts +++ b/packages/utils/src/store/ReactStore.spec.ts @@ -47,3 +47,27 @@ expectType(isTextLongerThan7Reactive) const isTextLengthBetween2And8Reactive = store.useState('textLengthBetween', 2, 8); expectType(isTextLengthBetween2And8Reactive); + +// incorrect calls: + +// @ts-expect-error +store.select(); +// @ts-expect-error +store.select('count', 1); +// @ts-expect-error +store.select('textLongerThan'); +// @ts-expect-error +store.select('textLengthBetween', 1); +// @ts-expect-error +store.select('textLongerThan', 2, 3); + +// @ts-expect-error +store.useState(); +// @ts-expect-error +store.useState('count', 1); +// @ts-expect-error +store.useState('textLongerThan'); +// @ts-expect-error +store.useState('textLengthBetween', 1); +// @ts-expect-error +store.useState('textLongerThan', 2, 3); diff --git a/packages/utils/src/store/ReactStore.ts b/packages/utils/src/store/ReactStore.ts index b09e01b0222..205ba5e5679 100644 --- a/packages/utils/src/store/ReactStore.ts +++ b/packages/utils/src/store/ReactStore.ts @@ -133,36 +133,6 @@ export class ReactStore< }, [key, controlled, defaultValue, isControlled]); } - /** Gets the current value from the store using a selector with the provided key. */ - public select(key: Key): ReturnType; - - public select( - key: Key, - a1: SelectorArgs[0], - ): ReturnType; - - public select( - key: Key, - a1: SelectorArgs[0], - a2: SelectorArgs[1], - ): ReturnType; - - public select( - key: Key, - a1: SelectorArgs[0], - a2: SelectorArgs[1], - a3: SelectorArgs[2], - ): ReturnType; - - public select( - key: Key, - a1?: SelectorArgs[0], - a2?: SelectorArgs[1], - a3?: SelectorArgs[2], - ): ReturnType { - return this.selectors![key](this.state, a1, a2, a3); - } - /** * Sets a specific key in the store's state to a new value and notifies listeners if the value has changed. * If the key is controlled (registered via {@link useControlledProp} with a non-undefined value), @@ -226,6 +196,15 @@ export class ReactStore< super.setState({ ...this.state, ...newValues }); } + /** Gets the current value from the store using a selector with the provided key. + * + * @param key Key of the selector to use. + */ + public select = ((key: keyof Selectors, a1?: unknown, a2?: unknown, a3?: unknown) => { + const selector = this.selectors![key]; + return selector(this.state, a1, a2, a3); + }) as ReactStoreSelectorMethod; + /** * Returns a value from the store's state using a selector function. * Used to subscribe to specific parts of the state. @@ -233,35 +212,10 @@ export class ReactStore< * * @param key Key of the selector to use. */ - public useState(key: Key): ReturnType; - - public useState( - key: Key, - a1: SelectorArgs[0], - ): ReturnType; - - public useState( - key: Key, - a1: SelectorArgs[0], - a2: SelectorArgs[1], - ): ReturnType; - - public useState( - key: Key, - a1: SelectorArgs[0], - a2: SelectorArgs[1], - a3: SelectorArgs[2], - ): ReturnType; - - public useState( - key: Key, - a1?: SelectorArgs[0], - a2?: SelectorArgs[1], - a3?: SelectorArgs[2], - ): ReturnType { + public useState = ((key: keyof Selectors, a1?: unknown, a2?: unknown, a3?: unknown) => { const selector = this.selectors![key]; return useStore(this, selector, a1, a2, a3); - } + }) as ReactStoreSelectorMethod; /** * Wraps a function with `useStableCallback` to ensure it has a stable reference @@ -362,6 +316,13 @@ type KeysAllowingUndefined = { [Key in keyof State]-?: undefined extends State[Key] ? Key : never; }[keyof State]; +type ReactStoreSelectorMethod>> = < + Key extends keyof Selectors, +>( + key: Key, + ...args: SelectorArgs +) => ReturnType; + type SelectorFunction = (state: State, ...args: any[]) => any; type Tail = T extends readonly [any, ...infer Rest] ? Rest : []; From 22150fc28318a3750fb9cdf6949b43a69b1b8b71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dudak?= Date: Thu, 30 Oct 2025 13:20:51 +0100 Subject: [PATCH 08/12] Remove guards --- docs/src/error-codes.json | 3 +-- packages/utils/src/store/ReactStore.ts | 6 +----- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/docs/src/error-codes.json b/docs/src/error-codes.json index cd4df62a9c7..ad29e80f77d 100644 --- a/docs/src/error-codes.json +++ b/docs/src/error-codes.json @@ -80,6 +80,5 @@ "79": "Base UI: must be used within or provided with a handle.", "80": "Base UI: PopoverHandle.open: No trigger found with id \"%s\".", "81": "Base UI: TooltipHandle.open: No trigger found with id \"%s\".", - "82": "Base UI: must be either used within a component or provided with a handle.", - "83": "Base UI: Selector for key \"%s\" is not defined." + "82": "Base UI: must be either used within a component or provided with a handle." } diff --git a/packages/utils/src/store/ReactStore.ts b/packages/utils/src/store/ReactStore.ts index 205ba5e5679..2d9e759b9c1 100644 --- a/packages/utils/src/store/ReactStore.ts +++ b/packages/utils/src/store/ReactStore.ts @@ -285,11 +285,7 @@ export class ReactStore< store: this, ) => void, ) { - if (!this.selectors || !Object.hasOwn(this.selectors, key)) { - throw new Error(`Base UI: Selector for key "${key as string}" is not defined.`); - } - - let prevValue = this.selectors[key]?.(this.state) as ReturnType; + let prevValue = this.selectors![key](this.state) as ReturnType; listener(prevValue, prevValue, this); From 74b4368ed6f60d9d49c92dda860b885cb7c20e65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dudak?= Date: Thu, 30 Oct 2025 14:08:41 +0100 Subject: [PATCH 09/12] Accept selector functions in observeSelector --- packages/utils/src/store/ReactStore.spec.ts | 23 ++++++++++++++++ packages/utils/src/store/ReactStore.test.tsx | 28 +++++++++++++++++++ packages/utils/src/store/ReactStore.ts | 29 ++++++++++++++++++-- 3 files changed, 77 insertions(+), 3 deletions(-) diff --git a/packages/utils/src/store/ReactStore.spec.ts b/packages/utils/src/store/ReactStore.spec.ts index 8351655c4ef..77f022fe6e5 100644 --- a/packages/utils/src/store/ReactStore.spec.ts +++ b/packages/utils/src/store/ReactStore.spec.ts @@ -71,3 +71,26 @@ store.useState('textLongerThan'); store.useState('textLengthBetween', 1); // @ts-expect-error store.useState('textLongerThan', 2, 3); + +const unsubscribeFromCount = store.observeSelector('count', (newValue, oldValue) => { + expectType(newValue); + expectType(oldValue); +}); +expectType<() => void, typeof unsubscribeFromCount>(unsubscribeFromCount); + +const unsubscribeFromSelector = store.observeSelector( + (state) => state.text.length, + (newValue, oldValue) => { + expectType(newValue); + expectType(oldValue); + }, +); +expectType<() => void, typeof unsubscribeFromSelector>(unsubscribeFromSelector); + +// @ts-expect-error listener must match selector return type +store.observeSelector( + (state) => state.text.length, + (newValue: string) => { + expectType(newValue); + }, +); diff --git a/packages/utils/src/store/ReactStore.test.tsx b/packages/utils/src/store/ReactStore.test.tsx index 79ccf64bac3..27126f501e5 100644 --- a/packages/utils/src/store/ReactStore.test.tsx +++ b/packages/utils/src/store/ReactStore.test.tsx @@ -474,6 +474,34 @@ describe('ReactStore', () => { multiplied: (state: CounterState) => state.count * state.multiplier, }; + it('accepts selector functions', () => { + const store = new ReactStore({ count: 0, multiplier: 1 }); + const calls: Array<{ newValue: boolean; oldValue: boolean }> = []; + + const unsubscribe = store.observeSelector( + (state) => state.count > 1, + (newValue, oldValue) => { + calls.push({ newValue, oldValue }); + }, + ); + + expect(calls).to.have.lengthOf(1); + expect(calls[0]).to.deep.equal({ newValue: false, oldValue: false }); + + store.set('count', 2); + expect(calls).to.have.lengthOf(2); + expect(calls[1]).to.deep.equal({ newValue: true, oldValue: false }); + + store.set('count', 1); + expect(calls).to.have.lengthOf(3); + expect(calls[2]).to.deep.equal({ newValue: false, oldValue: true }); + + unsubscribe(); + + store.set('count', 3); + expect(calls).to.have.lengthOf(3); + }); + it('calls listener immediately with current selector result on subscription', () => { const store = new ReactStore, typeof selectors>( { count: 5, multiplier: 3 }, diff --git a/packages/utils/src/store/ReactStore.ts b/packages/utils/src/store/ReactStore.ts index 2d9e759b9c1..07497fed7dc 100644 --- a/packages/utils/src/store/ReactStore.ts +++ b/packages/utils/src/store/ReactStore.ts @@ -278,19 +278,40 @@ export class ReactStore< * @param listener Listener function called when the selector result changes. */ public observeSelector( - key: Key, + selector: Key, listener: ( newValue: ReturnType, oldValue: ReturnType, store: this, ) => void, + ): () => void; + + public observeSelector>( + selector: Selector, + listener: (newValue: ReturnType, oldValue: ReturnType, store: this) => void, + ): () => void; + + public observeSelector( + selector: keyof Selectors | ObserveSelector, + listener: (newValue: any, oldValue: any, store: this) => void, ) { - let prevValue = this.selectors![key](this.state) as ReturnType; + let selectFn: ObserveSelector; + + if (typeof selector === 'function') { + selectFn = selector; + } else { + if (!this.selectors || !Object.hasOwn(this.selectors, selector)) { + throw new Error(`Base UI: Selector for key "${String(selector)}" is not defined.`); + } + selectFn = this.selectors[selector] as ObserveSelector; + } + + let prevValue = selectFn(this.state); listener(prevValue, prevValue, this); return this.subscribe((nextState) => { - const nextValue = this.selectors![key](nextState); + const nextValue = selectFn(nextState); if (!Object.is(prevValue, nextValue)) { const oldValue = prevValue; prevValue = nextValue; @@ -319,6 +340,8 @@ type ReactStoreSelectorMethod ) => ReturnType; +type ObserveSelector = (state: State) => any; + type SelectorFunction = (state: State, ...args: any[]) => any; type Tail = T extends readonly [any, ...infer Rest] ? Rest : []; From eee604dabf1b1edbeee04fef4338dfc8cf26a5d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dudak?= Date: Thu, 30 Oct 2025 14:10:50 +0100 Subject: [PATCH 10/12] Remove observeState --- packages/utils/src/store/ReactStore.test.tsx | 159 +------------------ packages/utils/src/store/ReactStore.ts | 24 --- 2 files changed, 5 insertions(+), 178 deletions(-) diff --git a/packages/utils/src/store/ReactStore.test.tsx b/packages/utils/src/store/ReactStore.test.tsx index 27126f501e5..2a743e6a754 100644 --- a/packages/utils/src/store/ReactStore.test.tsx +++ b/packages/utils/src/store/ReactStore.test.tsx @@ -3,6 +3,7 @@ import { expect } from 'chai'; import { act, createRenderer, screen } from '@mui/internal-test-utils'; import { ReactStore } from './ReactStore'; import { useRefWithInit } from '../useRefWithInit'; +import { createSelector } from './createSelector'; type TestState = { value: number; label: string }; @@ -238,6 +239,8 @@ describe('ReactStore', () => { parent: (state: ChildState) => state.parent, }; + const localCountSelector = createSelector((state: ChildState) => state.count); + const parentStore = new ReactStore, typeof parentSelectors>( { count: 0 }, undefined, @@ -274,8 +277,8 @@ describe('ReactStore', () => { store.state.parent?.set('count', newCount); }; - childStore.observeState('parent', onParentUpdated); - childStore.observeState('count', onCountUpdated); + childStore.observeSelector('parent', onParentUpdated); + childStore.observeSelector(localCountSelector, onCountUpdated); function Test() { const count = childStore.useState('count'); @@ -314,158 +317,6 @@ describe('ReactStore', () => { expect(childStore.select('count')).to.equal(15); expect(output.textContent).to.equal('15'); }); - - describe('observeState', () => { - it('calls listener immediately with current value on subscription', () => { - const store = new ReactStore({ value: 5, label: 'initial' }); - const calls: Array<{ newValue: number; oldValue: number }> = []; - - store.observeState('value', (newValue, oldValue) => { - calls.push({ newValue, oldValue }); - }); - - expect(calls).to.have.lengthOf(1); - expect(calls[0]).to.deep.equal({ newValue: 5, oldValue: 5 }); - }); - - it('calls listener when observed state key changes', () => { - const store = new ReactStore({ value: 5, label: 'initial' }); - const calls: Array<{ newValue: number; oldValue: number }> = []; - - store.observeState('value', (newValue, oldValue) => { - calls.push({ newValue, oldValue }); - }); - - store.set('value', 10); - store.set('value', 15); - - expect(calls).to.have.lengthOf(3); - expect(calls[1]).to.deep.equal({ newValue: 10, oldValue: 5 }); - expect(calls[2]).to.deep.equal({ newValue: 15, oldValue: 10 }); - }); - - it('does not call listener when non-observed state keys change', () => { - const store = new ReactStore({ value: 5, label: 'initial' }); - const calls: Array<{ newValue: number; oldValue: number }> = []; - - store.observeState('value', (newValue, oldValue) => { - calls.push({ newValue, oldValue }); - }); - - store.set('label', 'updated'); - - expect(calls).to.have.lengthOf(1); // Only initial call - }); - - it('does not call listener when value is set to same value (Object.is comparison)', () => { - const store = new ReactStore({ value: 5, label: 'initial' }); - const calls: Array<{ newValue: number; oldValue: number }> = []; - - store.observeState('value', (newValue, oldValue) => { - calls.push({ newValue, oldValue }); - }); - - store.set('value', 5); - - expect(calls).to.have.lengthOf(1); // Only initial call - }); - - it('provides the store instance to the listener', () => { - const store = new ReactStore({ value: 5, label: 'initial' }); - let receivedStore!: ReactStore; - - store.observeState('value', (_, __, storeArg) => { - receivedStore = storeArg; - }); - - expect(receivedStore).to.equal(store); - }); - - it('returns an unsubscribe function that stops observing', () => { - const store = new ReactStore({ value: 5, label: 'initial' }); - const calls: Array<{ newValue: number; oldValue: number }> = []; - - const unsubscribe = store.observeState('value', (newValue, oldValue) => { - calls.push({ newValue, oldValue }); - }); - - store.set('value', 10); - expect(calls).to.have.lengthOf(2); - - unsubscribe(); - - store.set('value', 15); - expect(calls).to.have.lengthOf(2); // No new calls after unsubscribe - }); - - it('supports multiple observers on the same key', () => { - const store = new ReactStore({ value: 5, label: 'initial' }); - const calls1: number[] = []; - const calls2: number[] = []; - - store.observeState('value', (newValue) => { - calls1.push(newValue); - }); - - store.observeState('value', (newValue) => { - calls2.push(newValue); - }); - - store.set('value', 10); - - expect(calls1).to.deep.equal([5, 10]); - expect(calls2).to.deep.equal([5, 10]); - }); - - it('supports observers on different keys', () => { - const store = new ReactStore({ value: 5, label: 'initial' }); - const valueCalls: number[] = []; - const labelCalls: string[] = []; - - store.observeState('value', (newValue) => { - valueCalls.push(newValue); - }); - - store.observeState('label', (newValue) => { - labelCalls.push(newValue); - }); - - store.set('value', 10); - store.set('label', 'updated'); - - expect(valueCalls).to.deep.equal([5, 10]); - expect(labelCalls).to.deep.equal(['initial', 'updated']); - }); - - it('tracks changes made through update method', () => { - const store = new ReactStore({ value: 5, label: 'initial' }); - const calls: Array<{ newValue: number; oldValue: number }> = []; - - store.observeState('value', (newValue, oldValue) => { - calls.push({ newValue, oldValue }); - }); - - store.update({ value: 20, label: 'updated' }); - - expect(calls).to.have.lengthOf(2); - expect(calls[1]).to.deep.equal({ newValue: 20, oldValue: 5 }); - }); - - it('tracks changes made through setState method', () => { - const store = new ReactStore({ value: 5, label: 'initial' }); - const calls: Array<{ newValue: number; oldValue: number }> = []; - - store.observeState('value', (newValue, oldValue) => { - calls.push({ newValue, oldValue }); - }); - - store.setState({ value: 25, label: 'new' }); - - expect(calls).to.have.lengthOf(2); - expect(calls[1]).to.deep.equal({ newValue: 25, oldValue: 5 }); - }); - }); - describe('observeSelector', () => { type CounterState = { count: number; multiplier: number }; const selectors = { diff --git a/packages/utils/src/store/ReactStore.ts b/packages/utils/src/store/ReactStore.ts index 07497fed7dc..f3882f3a442 100644 --- a/packages/utils/src/store/ReactStore.ts +++ b/packages/utils/src/store/ReactStore.ts @@ -247,30 +247,6 @@ export class ReactStore< ); } - /** - * Observes changes in the state properties and calls the listener when the value changes. - * - * @param key Key of the state property to observe. - * @param listener Listener function called when the value changes. - */ - public observeState( - key: Key, - listener: (newValue: State[Key], oldValue: State[Key], store: this) => void, - ) { - let prevValue = this.state[key]; - - listener(prevValue, prevValue, this); - - return this.subscribe((nextState) => { - const nextValue = nextState[key]; - if (!Object.is(prevValue, nextValue)) { - const oldValue = prevValue; - prevValue = nextValue; - listener(nextValue, oldValue, this); - } - }); - } - /** * Observes changes derived from the store's selectors and calls the listener when the selected value changes. * From b528cce98d69ca33e45139786c2c005735e1e386 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dudak?= Date: Thu, 30 Oct 2025 14:19:44 +0100 Subject: [PATCH 11/12] Remove guards --- packages/utils/src/store/ReactStore.test.tsx | 20 -------------------- packages/utils/src/store/ReactStore.ts | 5 +---- 2 files changed, 1 insertion(+), 24 deletions(-) diff --git a/packages/utils/src/store/ReactStore.test.tsx b/packages/utils/src/store/ReactStore.test.tsx index 2a743e6a754..0df687f359d 100644 --- a/packages/utils/src/store/ReactStore.test.tsx +++ b/packages/utils/src/store/ReactStore.test.tsx @@ -463,26 +463,6 @@ describe('ReactStore', () => { expect(calls).to.have.lengthOf(2); // No new calls after unsubscribe }); - it('throws error when selector key does not exist', () => { - const store = new ReactStore, typeof selectors>( - { count: 5, multiplier: 3 }, - undefined, - selectors, - ); - - expect(() => { - store.observeSelector('nonexistent' as any, () => {}); - }).to.throw('Base UI: Selector for key "nonexistent" is not defined.'); - }); - - it('throws error when store has no selectors', () => { - const store = new ReactStore({ count: 5, multiplier: 3 }); - - expect(() => { - store.observeSelector('doubled' as any, () => {}); - }).to.throw('Base UI: Selector for key "doubled" is not defined.'); - }); - it('supports multiple observers on the same selector', () => { const store = new ReactStore, typeof selectors>( { count: 5, multiplier: 3 }, diff --git a/packages/utils/src/store/ReactStore.ts b/packages/utils/src/store/ReactStore.ts index f3882f3a442..a7c96d9a6e7 100644 --- a/packages/utils/src/store/ReactStore.ts +++ b/packages/utils/src/store/ReactStore.ts @@ -276,10 +276,7 @@ export class ReactStore< if (typeof selector === 'function') { selectFn = selector; } else { - if (!this.selectors || !Object.hasOwn(this.selectors, selector)) { - throw new Error(`Base UI: Selector for key "${String(selector)}" is not defined.`); - } - selectFn = this.selectors[selector] as ObserveSelector; + selectFn = this.selectors![selector] as ObserveSelector; } let prevValue = selectFn(this.state); From f75116dae1795ec9d066e8cf4f6da1f3fc069154 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dudak?= Date: Fri, 31 Oct 2025 09:35:12 +0100 Subject: [PATCH 12/12] observeSelector -> observe --- packages/utils/src/store/ReactStore.spec.ts | 6 ++--- packages/utils/src/store/ReactStore.test.tsx | 26 ++++++++++---------- packages/utils/src/store/ReactStore.ts | 6 ++--- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/packages/utils/src/store/ReactStore.spec.ts b/packages/utils/src/store/ReactStore.spec.ts index 77f022fe6e5..3b11d01a416 100644 --- a/packages/utils/src/store/ReactStore.spec.ts +++ b/packages/utils/src/store/ReactStore.spec.ts @@ -72,13 +72,13 @@ store.useState('textLengthBetween', 1); // @ts-expect-error store.useState('textLongerThan', 2, 3); -const unsubscribeFromCount = store.observeSelector('count', (newValue, oldValue) => { +const unsubscribeFromCount = store.observe('count', (newValue, oldValue) => { expectType(newValue); expectType(oldValue); }); expectType<() => void, typeof unsubscribeFromCount>(unsubscribeFromCount); -const unsubscribeFromSelector = store.observeSelector( +const unsubscribeFromSelector = store.observe( (state) => state.text.length, (newValue, oldValue) => { expectType(newValue); @@ -88,7 +88,7 @@ const unsubscribeFromSelector = store.observeSelector( expectType<() => void, typeof unsubscribeFromSelector>(unsubscribeFromSelector); // @ts-expect-error listener must match selector return type -store.observeSelector( +store.observe( (state) => state.text.length, (newValue: string) => { expectType(newValue); diff --git a/packages/utils/src/store/ReactStore.test.tsx b/packages/utils/src/store/ReactStore.test.tsx index 0df687f359d..fc09dce6f73 100644 --- a/packages/utils/src/store/ReactStore.test.tsx +++ b/packages/utils/src/store/ReactStore.test.tsx @@ -277,8 +277,8 @@ describe('ReactStore', () => { store.state.parent?.set('count', newCount); }; - childStore.observeSelector('parent', onParentUpdated); - childStore.observeSelector(localCountSelector, onCountUpdated); + childStore.observe('parent', onParentUpdated); + childStore.observe(localCountSelector, onCountUpdated); function Test() { const count = childStore.useState('count'); @@ -329,7 +329,7 @@ describe('ReactStore', () => { const store = new ReactStore({ count: 0, multiplier: 1 }); const calls: Array<{ newValue: boolean; oldValue: boolean }> = []; - const unsubscribe = store.observeSelector( + const unsubscribe = store.observe( (state) => state.count > 1, (newValue, oldValue) => { calls.push({ newValue, oldValue }); @@ -361,7 +361,7 @@ describe('ReactStore', () => { ); const calls: Array<{ newValue: number; oldValue: number }> = []; - store.observeSelector('doubled', (newValue: number, oldValue: number) => { + store.observe('doubled', (newValue: number, oldValue: number) => { calls.push({ newValue, oldValue }); }); @@ -377,7 +377,7 @@ describe('ReactStore', () => { ); const calls: Array<{ newValue: number; oldValue: number }> = []; - store.observeSelector('doubled', (newValue: number, oldValue: number) => { + store.observe('doubled', (newValue: number, oldValue: number) => { calls.push({ newValue, oldValue }); }); @@ -397,7 +397,7 @@ describe('ReactStore', () => { ); const calls: Array<{ newValue: number; oldValue: number }> = []; - store.observeSelector('doubled', (newValue: number, oldValue: number) => { + store.observe('doubled', (newValue: number, oldValue: number) => { calls.push({ newValue, oldValue }); }); @@ -414,7 +414,7 @@ describe('ReactStore', () => { ); const calls: Array<{ newValue: number; oldValue: number }> = []; - store.observeSelector('multiplied', (newValue: number, oldValue: number) => { + store.observe('multiplied', (newValue: number, oldValue: number) => { calls.push({ newValue, oldValue }); }); @@ -435,7 +435,7 @@ describe('ReactStore', () => { ); let receivedStore!: ReactStore, typeof selectors>; - store.observeSelector('doubled', (_: number, __: number, storeArg) => { + store.observe('doubled', (_: number, __: number, storeArg) => { receivedStore = storeArg; }); @@ -450,7 +450,7 @@ describe('ReactStore', () => { ); const calls: Array<{ newValue: number; oldValue: number }> = []; - const unsubscribe = store.observeSelector('doubled', (newValue: number, oldValue: number) => { + const unsubscribe = store.observe('doubled', (newValue: number, oldValue: number) => { calls.push({ newValue, oldValue }); }); @@ -472,11 +472,11 @@ describe('ReactStore', () => { const calls1: number[] = []; const calls2: number[] = []; - store.observeSelector('doubled', (newValue: number) => { + store.observe('doubled', (newValue: number) => { calls1.push(newValue); }); - store.observeSelector('doubled', (newValue: number) => { + store.observe('doubled', (newValue: number) => { calls2.push(newValue); }); @@ -495,11 +495,11 @@ describe('ReactStore', () => { const doubledCalls: number[] = []; const multipliedCalls: number[] = []; - store.observeSelector('doubled', (newValue: number) => { + store.observe('doubled', (newValue: number) => { doubledCalls.push(newValue); }); - store.observeSelector('multiplied', (newValue: number) => { + store.observe('multiplied', (newValue: number) => { multipliedCalls.push(newValue); }); diff --git a/packages/utils/src/store/ReactStore.ts b/packages/utils/src/store/ReactStore.ts index a7c96d9a6e7..0511bc73cd7 100644 --- a/packages/utils/src/store/ReactStore.ts +++ b/packages/utils/src/store/ReactStore.ts @@ -253,7 +253,7 @@ export class ReactStore< * @param key Key of the selector to observe. * @param listener Listener function called when the selector result changes. */ - public observeSelector( + public observe( selector: Key, listener: ( newValue: ReturnType, @@ -262,12 +262,12 @@ export class ReactStore< ) => void, ): () => void; - public observeSelector>( + public observe>( selector: Selector, listener: (newValue: ReturnType, oldValue: ReturnType, store: this) => void, ): () => void; - public observeSelector( + public observe( selector: keyof Selectors | ObserveSelector, listener: (newValue: any, oldValue: any, store: this) => void, ) {