Skip to content

Commit 7ffe634

Browse files
authored
[utils] Enforce store state key/value types (#5423)
1 parent 1aef3bf commit 7ffe634

20 files changed

Lines changed: 359 additions & 198 deletions

File tree

packages/react/src/combobox/root/AriaCombobox.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -509,7 +509,18 @@ export function AriaCombobox<Value = any, Mode extends SelectionMode = 'none'>(
509509
selectedIndex?: number | null | undefined;
510510
type?: AriaCombobox.HighlightEventReason | undefined;
511511
}) => {
512-
store.update(options);
512+
const update = {} as Pick<StoreState, 'activeIndex' | 'selectedIndex'>;
513+
514+
if (options.activeIndex !== undefined) {
515+
update.activeIndex = options.activeIndex;
516+
}
517+
518+
if (options.selectedIndex !== undefined) {
519+
update.selectedIndex = options.selectedIndex;
520+
}
521+
522+
store.update(update);
523+
513524
const activeIndexOption = options.activeIndex;
514525
if (activeIndexOption === undefined) {
515526
return;

packages/react/src/dialog/store/DialogStore.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
PopupTriggerDataStore,
1111
PopupStoreState,
1212
PopupTriggerMap,
13-
setPopupOpenState,
13+
createPopupOpenState,
1414
} from '../../utils/popups';
1515

1616
export type State<Payload> = PopupStoreState<Payload> & {
@@ -93,13 +93,7 @@ export class DialogStore<Payload> extends ReactStore<
9393

9494
this.state.floatingRootContext.dispatchOpenChange(nextOpen, eventDetails);
9595

96-
const updatedState: Partial<State<Payload>> = {
97-
open: nextOpen,
98-
};
99-
100-
setPopupOpenState(updatedState, nextOpen, eventDetails.trigger);
101-
102-
this.update(updatedState);
96+
this.update(createPopupOpenState(this.state, nextOpen, eventDetails.trigger));
10397
};
10498
}
10599

packages/react/src/floating-ui-react/hooks/useFloatingRootContext.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ import { useRefWithInit } from '@base-ui/utils/useRefWithInit';
66
import { PopupTriggerMap } from '../../utils/popups';
77
import type { BaseUIChangeEventDetails } from '../../internals/createBaseUIEventDetails';
88
import { useFloatingParentNodeId } from '../components/FloatingTree';
9-
import { FloatingRootStore, type FloatingRootState } from '../components/FloatingRootStore';
9+
import {
10+
FloatingRootStore,
11+
type FloatingRootState as State,
12+
} from '../components/FloatingRootStore';
1013
import type { ReferenceType } from '../types';
1114

1215
export interface UseFloatingRootContextOptions {
@@ -53,12 +56,11 @@ export function useFloatingRootContext(options: UseFloatingRootContextOptions):
5356
).current;
5457

5558
useIsoLayoutEffect(() => {
56-
const valuesToSync: Writeable<Partial<FloatingRootState>> = {
57-
open,
58-
floatingId,
59-
};
59+
const valuesToSync = { open, floatingId } as Pick<
60+
State,
61+
'open' | 'floatingId' | 'referenceElement' | 'domReferenceElement' | 'floatingElement'
62+
>;
6063

61-
// Only sync elements that are defined to avoid overwriting existing ones
6264
if (elements.reference !== undefined) {
6365
valuesToSync.referenceElement = elements.reference;
6466
valuesToSync.domReferenceElement = isElement(elements.reference) ? elements.reference : null;
@@ -76,5 +78,3 @@ export function useFloatingRootContext(options: UseFloatingRootContextOptions):
7678

7779
return store;
7880
}
79-
80-
type Writeable<T> = { -readonly [P in keyof T]: T[P] };

packages/react/src/floating-ui-react/hooks/useSyncedFloatingRootContext.ts

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,26 @@
11
'use client';
22
import * as React from 'react';
33
import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect';
4-
import { ReactStore } from '@base-ui/utils/store';
4+
import type { ReactStore } from '@base-ui/utils/store';
55
import { isElement } from '@floating-ui/utils/dom';
66
import { BaseUIChangeEventDetails } from '../../types';
77
import { PopupStoreContext, PopupStoreSelectors, PopupStoreState } from '../../utils/popups';
88
import { FloatingRootState, FloatingRootStore } from '../components/FloatingRootStore';
99

10+
/**
11+
* Narrowed to the store members this hook uses so consumers do not need to provide
12+
* unrelated store capabilities.
13+
*/
14+
export type SyncedFloatingRootContextStore<State extends PopupStoreState<unknown>> = Pick<
15+
ReactStore<Readonly<State>, PopupStoreContext<never>, PopupStoreSelectors>,
16+
'context' | 'state' | 'useState' | 'useSyncedValue'
17+
>;
18+
1019
export interface UseSyncedFloatingRootContextOptions<
1120
State extends PopupStoreState<unknown>,
12-
ContextEventDetails extends BaseUIChangeEventDetails<string>,
1321
OpenChangeEventDetails extends BaseUIChangeEventDetails<string>,
1422
> {
15-
popupStore: ReactStore<State, PopupStoreContext<ContextEventDetails>, PopupStoreSelectors>;
23+
popupStore: SyncedFloatingRootContextStore<State>;
1624
/**
1725
* Whether the Popup element is passed to Floating UI as the floating element instead of the default Positioner.
1826
*/
@@ -29,11 +37,8 @@ export interface UseSyncedFloatingRootContextOptions<
2937
*/
3038
export function useSyncedFloatingRootContext<
3139
State extends PopupStoreState<unknown>,
32-
ContextEventDetails extends BaseUIChangeEventDetails<string>,
3340
OpenChangeEventDetails extends BaseUIChangeEventDetails<string>,
34-
>(
35-
options: UseSyncedFloatingRootContextOptions<State, ContextEventDetails, OpenChangeEventDetails>,
36-
): FloatingRootStore {
41+
>(options: UseSyncedFloatingRootContextOptions<State, OpenChangeEventDetails>): FloatingRootStore {
3742
const {
3843
popupStore,
3944
treatPopupAsFloatingElement = false,
@@ -75,12 +80,20 @@ export function useSyncedFloatingRootContext<
7580
popupStore.useSyncedValue('floatingId', floatingId as State['floatingId']);
7681

7782
useIsoLayoutEffect(() => {
78-
const valuesToSync: Partial<FloatingRootState> = {
83+
const valuesToSync = {
7984
open,
8085
floatingId,
8186
referenceElement,
8287
floatingElement,
83-
};
88+
} as Pick<
89+
FloatingRootState,
90+
| 'open'
91+
| 'floatingId'
92+
| 'referenceElement'
93+
| 'floatingElement'
94+
| 'domReferenceElement'
95+
| 'positionReference'
96+
>;
8497

8598
if (isElement(referenceElement)) {
8699
valuesToSync.domReferenceElement = referenceElement;

packages/react/src/menu/root/MenuRoot.tsx

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ import {
3737
attachPreventUnmountOnClose,
3838
FOCUSABLE_POPUP_PROPS,
3939
PayloadChildRenderFunction,
40-
setPopupOpenState,
40+
createPopupOpenState,
4141
PopupHandleAttachment,
4242
useImplicitActiveTrigger,
4343
useOpenStateTransitions,
@@ -301,20 +301,19 @@ export const MenuRoot = fastComponent(function MenuRoot<Payload>(props: MenuRoot
301301
(nativeEvent as MouseEvent).detail === 0;
302302
const isDismissClose = !nextOpen && (reason === REASONS.escapeKey || reason == null);
303303

304-
const updatedState: Partial<MenuStoreState<Payload>> = {
305-
open: nextOpen,
306-
openChangeReason: reason,
307-
};
308304
openEventRef.current = eventDetails.event;
309305

310-
setPopupOpenState(
311-
updatedState,
306+
const popupOpenState = createPopupOpenState(
307+
store.state,
312308
nextOpen,
313309
eventDetails.trigger,
314310
shouldPreventUnmountOnClose(),
315-
);
311+
) as ReturnType<typeof createPopupOpenState> & {
312+
openChangeReason: MenuRoot.ChangeEventReason;
313+
};
316314

317-
store.update(updatedState);
315+
popupOpenState.openChangeReason = reason;
316+
store.update(popupOpenState);
318317

319318
if (
320319
parent.type === 'menubar' &&

packages/react/src/menu/store/MenuStore.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818

1919
export type State<Payload> = PopupStoreState<Payload> & {
2020
disabled: boolean;
21-
modal: boolean;
21+
modal: boolean | undefined;
2222
openMethod: InteractionType | null;
2323
allowMouseEnter: boolean;
2424
highlightItemOnHover: boolean;

packages/react/src/menu/submenu-trigger/MenuSubmenuTrigger.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export const MenuSubmenuTrigger = React.forwardRef(function MenuSubmenuTrigger(
6767

6868
if (element !== null && store.select('open') && store.select('activeTriggerId') == null) {
6969
store.update({
70-
activeTriggerId: thisTriggerId,
70+
activeTriggerId: thisTriggerId ?? null,
7171
activeTriggerElement: element,
7272
closeDelay,
7373
});

packages/react/src/popover/store/PopoverStore.ts

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
PopupStoreState,
1717
PopupTriggerMap,
1818
type PopupTriggerStoreKeys,
19-
setPopupOpenState,
19+
createPopupOpenState,
2020
} from '../../utils/popups';
2121
import { PATIENT_CLICK_THRESHOLD } from '../../internals/constants';
2222
import type { AdaptiveOriginMiddleware } from '../../utils/adaptiveOriginConstants';
@@ -130,19 +130,17 @@ export class PopoverStore<Payload> extends ReactStore<
130130
this.state.floatingRootContext.dispatchOpenChange(nextOpen, eventDetails);
131131

132132
const changeState = () => {
133-
const updatedState: Partial<State<Payload>> = {
134-
open: nextOpen,
135-
openChangeReason: eventDetails.reason,
136-
};
137-
138-
setPopupOpenState(
139-
updatedState,
133+
const popupOpenState = createPopupOpenState(
134+
this.state,
140135
nextOpen,
141136
eventDetails.trigger,
142137
shouldPreventUnmountOnClose(),
143-
);
138+
) as ReturnType<typeof createPopupOpenState> & {
139+
openChangeReason: PopoverRoot.ChangeEventReason;
140+
};
144141

145-
this.update(updatedState);
142+
popupOpenState.openChangeReason = eventDetails.reason;
143+
this.update(popupOpenState);
146144
};
147145

148146
if (isHover) {

packages/react/src/preview-card/store/PreviewCardStore.ts

Lines changed: 21 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -72,33 +72,28 @@ export class PreviewCardStore<Payload> extends ReactStore<
7272
) => {
7373
const { inlineRectCoordsRef } = this.context;
7474

75-
applyPopupOpenChange<State<Payload>, PreviewCardRoot.ChangeEventDetails>(
76-
this,
77-
nextOpen,
78-
eventDetails as PreviewCardRoot.ChangeEventDetails,
79-
{
80-
onBeforeDispatch() {
81-
// Capture the hovered inline-rect coordinates so the card anchors to the
82-
// exact point on the link that was hovered.
83-
const event = eventDetails.event;
84-
if (
85-
nextOpen &&
86-
eventDetails.reason === REASONS.triggerHover &&
87-
eventDetails.trigger &&
88-
'clientX' in event &&
89-
'clientY' in event &&
90-
inlineRectCoordsRef.current?.element !== eventDetails.trigger
91-
) {
92-
updateInlineRectCoords(
93-
inlineRectCoordsRef,
94-
eventDetails.trigger,
95-
event.clientX,
96-
event.clientY,
97-
);
98-
}
99-
},
75+
applyPopupOpenChange(this, nextOpen, eventDetails as PreviewCardRoot.ChangeEventDetails, {
76+
onBeforeDispatch() {
77+
// Capture the hovered inline-rect coordinates so the card anchors to the
78+
// exact point on the link that was hovered.
79+
const event = eventDetails.event;
80+
if (
81+
nextOpen &&
82+
eventDetails.reason === REASONS.triggerHover &&
83+
eventDetails.trigger &&
84+
'clientX' in event &&
85+
'clientY' in event &&
86+
inlineRectCoordsRef.current?.element !== eventDetails.trigger
87+
) {
88+
updateInlineRectCoords(
89+
inlineRectCoordsRef,
90+
eventDetails.trigger,
91+
event.clientX,
92+
event.clientY,
93+
);
94+
}
10095
},
101-
);
96+
});
10297
};
10398
}
10499

packages/react/src/toast/store.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -123,10 +123,10 @@ export class ToastStore extends ReactStore<State, {}, typeof selectors> {
123123
return;
124124
}
125125

126-
const updates: Partial<State> = {
127-
timeout,
128-
limit,
129-
};
126+
const updates = { timeout, limit } as Pick<
127+
State,
128+
'timeout' | 'limit' | 'toasts' | 'toastMetadata'
129+
>;
130130

131131
if (limitChanged) {
132132
const newToasts = applyLimited(this.state.toasts, limit);
@@ -446,14 +446,16 @@ export class ToastStore extends ReactStore<State, {}, typeof selectors> {
446446
}
447447

448448
private setToasts(newToasts: StoredToast[], clearInteraction: boolean = newToasts.length === 0) {
449-
const updates: Partial<State> = {
449+
const updates = {
450450
toasts: newToasts,
451451
toastMetadata: createToastMetadata(newToasts),
452-
};
452+
} as Pick<State, 'toasts' | 'toastMetadata' | 'hovering' | 'focused'>;
453+
453454
if (clearInteraction) {
454455
updates.hovering = false;
455456
updates.focused = false;
456457
}
458+
457459
this.update(updates);
458460
}
459461

0 commit comments

Comments
 (0)