Skip to content

Commit 0d72161

Browse files
committed
[popups] Cover interaction ownership regressions
1 parent ce6a38c commit 0d72161

10 files changed

Lines changed: 217 additions & 27 deletions

File tree

packages/react/src/dialog/popup/DialogPopup.test.tsx

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { expect, vi } from 'vitest';
22
import * as React from 'react';
33
import { Dialog } from '@base-ui/react/dialog';
44
import { AlertDialog } from '@base-ui/react/alert-dialog';
5-
import { act, waitFor, screen } from '@mui/internal-test-utils';
5+
import { act, fireEvent, waitFor, screen } from '@mui/internal-test-utils';
66
import { describeConformance, createRenderer, isJSDOM, waitSingleFrame } from '#test-utils';
77

88
describe('<Dialog.Popup />', () => {
@@ -189,6 +189,39 @@ describe('<Dialog.Popup />', () => {
189189
});
190190
});
191191

192+
it('passes the latest interaction type to initialFocus after reopening', async () => {
193+
const initialFocus = vi.fn(() => false);
194+
195+
const { user } = await render(
196+
<Dialog.Root modal={false}>
197+
<Dialog.Trigger>Open</Dialog.Trigger>
198+
<Dialog.Portal>
199+
<Dialog.Popup initialFocus={initialFocus}>Content</Dialog.Popup>
200+
</Dialog.Portal>
201+
</Dialog.Root>,
202+
);
203+
204+
const trigger = screen.getByText('Open');
205+
await act(async () => trigger.focus());
206+
await user.keyboard('[Enter]');
207+
208+
await waitFor(() => {
209+
expect(initialFocus).toHaveBeenLastCalledWith('keyboard');
210+
});
211+
212+
await user.keyboard('[Escape]');
213+
await waitFor(() => {
214+
expect(screen.queryByRole('dialog')).toBe(null);
215+
});
216+
217+
fireEvent.pointerDown(trigger, { pointerType: 'touch' });
218+
fireEvent.click(trigger, { detail: 1 });
219+
220+
await waitFor(() => {
221+
expect(initialFocus).toHaveBeenLastCalledWith('touch');
222+
});
223+
});
224+
192225
it('should not move focus when initialFocus is false', async () => {
193226
function TestComponent() {
194227
return (

packages/react/src/dialog/root/DialogRoot.test.tsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,42 @@ describe('<Dialog.Root />', () => {
3232
expectedPopupRole: 'dialog',
3333
});
3434

35+
it('keeps trigger ownership when another trigger mounts while open', async () => {
36+
function Test() {
37+
const [showSecondTrigger, setShowSecondTrigger] = React.useState(false);
38+
39+
return (
40+
<Dialog.Root modal={false}>
41+
<Dialog.Trigger id="trigger-1">Trigger 1</Dialog.Trigger>
42+
{showSecondTrigger && <Dialog.Trigger id="trigger-2">Trigger 2</Dialog.Trigger>}
43+
<Dialog.Portal>
44+
<Dialog.Popup>
45+
<button onClick={() => setShowSecondTrigger(true)}>Mount trigger 2</button>
46+
</Dialog.Popup>
47+
</Dialog.Portal>
48+
</Dialog.Root>
49+
);
50+
}
51+
52+
const { user } = await render(<Test />);
53+
const trigger1 = screen.getByRole('button', { name: 'Trigger 1' });
54+
55+
await user.click(trigger1);
56+
57+
const popup = await screen.findByRole('dialog');
58+
const trigger1Controls = trigger1.getAttribute('aria-controls');
59+
60+
expect(trigger1Controls).toBe(popup.getAttribute('id'));
61+
62+
await user.click(screen.getByRole('button', { name: 'Mount trigger 2' }));
63+
64+
const trigger2 = screen.getByRole('button', { name: 'Trigger 2' });
65+
expect(trigger1).toHaveAttribute('aria-expanded', 'true');
66+
expect(trigger1.getAttribute('aria-controls')).toBe(trigger1Controls);
67+
expect(trigger2).toHaveAttribute('aria-expanded', 'false');
68+
expect(trigger2).not.toHaveAttribute('aria-controls');
69+
});
70+
3571
describe.for([
3672
{ name: 'contained triggers', Component: ContainedTriggerDialog },
3773
{ name: 'detached triggers', Component: DetachedTriggerDialog },

packages/react/src/floating-ui-react/hooks/useClientPoint.test.tsx

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1-
import { test, expect } from 'vitest';
1+
import { test, expect, vi } from 'vitest';
22
import * as React from 'react';
33
import type { Coords } from '@floating-ui/react-dom';
44
import { flushMicrotasks } from '@mui/internal-test-utils';
55
import { fireEvent, render, screen } from '@testing-library/react';
66
import { createChangeEventDetails } from '../../internals/createBaseUIEventDetails';
77
import { REASONS } from '../../internals/reasons';
8+
import { PopupTriggerMap } from '../../utils/popups';
9+
import { FloatingRootStore } from '../components/FloatingRootStore';
810
import { useClientPoint, useFloating, useInteractions } from '../index';
911

1012
function expectLocation({ x, y }: Coords) {
@@ -21,6 +23,20 @@ function expectRect({ x, y, width, height }: DOMRectInit) {
2123
expect(Number(screen.getByTestId('height')?.textContent)).toBe(height);
2224
}
2325

26+
function createRootStore(referenceElement: HTMLElement) {
27+
return new FloatingRootStore({
28+
open: false,
29+
transitionStatus: undefined,
30+
referenceElement,
31+
floatingElement: document.createElement('div'),
32+
triggerElements: new PopupTriggerMap(),
33+
floatingId: undefined,
34+
syncOnly: false,
35+
nested: false,
36+
onOpenChange: vi.fn(),
37+
});
38+
}
39+
2440
function App({
2541
enabled = true,
2642
axis,
@@ -86,6 +102,17 @@ function App({
86102
);
87103
}
88104

105+
function ClientPointStoreTest({
106+
store,
107+
enabled = true,
108+
}: {
109+
store: FloatingRootStore;
110+
enabled?: boolean;
111+
}) {
112+
useClientPoint(store, { enabled });
113+
return null;
114+
}
115+
89116
test('updates position from trigger props', async () => {
90117
render(<App useTriggerProps />);
91118

@@ -264,6 +291,28 @@ test('cleans up window listener when closing or disabling', async () => {
264291
expectRect({ x: 0, y: 0, width: 0, height: 0 });
265292
});
266293

294+
test('clears virtual references on unmount without retaining a stale DOM reference', async () => {
295+
const reference = document.createElement('button');
296+
const store = createRootStore(reference);
297+
const virtualReference = {
298+
getBoundingClientRect: () => reference.getBoundingClientRect(),
299+
};
300+
301+
store.set('positionReference', virtualReference);
302+
303+
const { rerender, unmount } = render(<ClientPointStoreTest store={store} />);
304+
305+
rerender(<ClientPointStoreTest store={store} enabled={false} />);
306+
await flushMicrotasks();
307+
308+
expect(store.state.positionReference).toBe(reference);
309+
310+
store.set('positionReference', virtualReference);
311+
unmount();
312+
313+
expect(store.state.positionReference).toBe(null);
314+
});
315+
267316
test('axis x', async () => {
268317
render(<App axis="x" />);
269318

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

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -126,8 +126,8 @@ export function useClientPoint(
126126
const [pointerType, setPointerType] = React.useState<string | undefined>();
127127
const [reactive, setReactive] = React.useState([]);
128128

129-
const resetReference = useStableCallback(() => {
130-
store.set('positionReference', domReference);
129+
const resetReference = useStableCallback((reference: Element | null) => {
130+
store.set('positionReference', reference);
131131
});
132132

133133
const setReference = useStableCallback(
@@ -176,7 +176,7 @@ export function useClientPoint(
176176

177177
React.useEffect(() => {
178178
if (!enabled) {
179-
resetReference();
179+
resetReference(domReference);
180180
return undefined;
181181
}
182182

@@ -204,7 +204,7 @@ export function useClientPoint(
204204
if (!dataRef.current.openEvent || isMouseBasedEvent(dataRef.current.openEvent)) {
205205
cleanupListenerRef.current = addEventListener(win, 'mousemove', handleMouseMove);
206206
} else {
207-
store.set('positionReference', domReference);
207+
resetReference(domReference);
208208
}
209209

210210
return cleanupListener;
@@ -220,8 +220,13 @@ export function useClientPoint(
220220
reactive,
221221
]);
222222

223-
// Keep this separate from the main effect so disabling cursor tracking also clears the virtual reference.
224-
React.useEffect(() => resetReference, [resetReference]);
223+
// Clear virtual cursor references when the hook unmounts. Enabled flips are handled above.
224+
React.useEffect(
225+
() => () => {
226+
store.set('positionReference', null);
227+
},
228+
[store],
229+
);
225230

226231
React.useEffect(() => {
227232
if (enabled && !floating) {
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { expect, test, vi } from 'vitest';
2+
import * as React from 'react';
3+
import { flushMicrotasks, render } from '@mui/internal-test-utils';
4+
import { PopupTriggerMap } from '../../utils/popups';
5+
import { FloatingRootStore } from '../components/FloatingRootStore';
6+
import { useFloating } from './useFloating';
7+
8+
function createRootStore(floatingElement: HTMLElement) {
9+
return new FloatingRootStore({
10+
open: true,
11+
transitionStatus: undefined,
12+
referenceElement: document.createElement('button'),
13+
floatingElement,
14+
triggerElements: new PopupTriggerMap(),
15+
floatingId: undefined,
16+
syncOnly: false,
17+
nested: false,
18+
onOpenChange: vi.fn(),
19+
});
20+
}
21+
22+
function Test({ rootContext }: { rootContext: FloatingRootStore }) {
23+
useFloating({ rootContext });
24+
return null;
25+
}
26+
27+
test('preserves an externally synced floating element when the root context changes', async () => {
28+
const firstFloatingElement = document.createElement('div');
29+
const secondFloatingElement = document.createElement('div');
30+
const firstStore = createRootStore(firstFloatingElement);
31+
const secondStore = createRootStore(secondFloatingElement);
32+
33+
const { rerender } = render(<Test rootContext={firstStore} />);
34+
await flushMicrotasks();
35+
36+
expect(firstStore.state.floatingElement).toBe(firstFloatingElement);
37+
38+
rerender(<Test rootContext={secondStore} />);
39+
await flushMicrotasks();
40+
41+
expect(secondStore.state.floatingElement).toBe(secondFloatingElement);
42+
});

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ export function useSyncedFloatingRootContext<
9393
store.update(valuesToSync);
9494
}, [open, floatingId, referenceElement, floatingElement, store]);
9595

96-
// TODO: When `setOpen` is a part of the PopupStore API, we don't need to sync it.
96+
// Keep non-reactive context values fresh for interactions that call `store.setOpen`.
9797
store.context.onOpenChange = handleOpenChange;
9898
store.context.nested = nested;
9999

packages/react/src/popover/popup/PopoverPopup.test.tsx

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { expect } from 'vitest';
1+
import { expect, vi } from 'vitest';
22
import * as React from 'react';
33
import { Popover } from '@base-ui/react/popover';
44
import { act, fireEvent, flushMicrotasks, screen, waitFor } from '@mui/internal-test-utils';
@@ -187,6 +187,41 @@ describe('<Popover.Popup />', () => {
187187
});
188188
});
189189

190+
it('passes the latest interaction type to initialFocus after reopening', async () => {
191+
const initialFocus = vi.fn(() => false);
192+
193+
const { user } = await render(
194+
<Popover.Root>
195+
<Popover.Trigger>Open</Popover.Trigger>
196+
<Popover.Portal>
197+
<Popover.Positioner>
198+
<Popover.Popup initialFocus={initialFocus}>Content</Popover.Popup>
199+
</Popover.Positioner>
200+
</Popover.Portal>
201+
</Popover.Root>,
202+
);
203+
204+
const trigger = screen.getByText('Open');
205+
await act(async () => trigger.focus());
206+
await user.keyboard('[Enter]');
207+
208+
await waitFor(() => {
209+
expect(initialFocus).toHaveBeenLastCalledWith('keyboard');
210+
});
211+
212+
await user.keyboard('{Escape}');
213+
await waitFor(() => {
214+
expect(screen.queryByRole('dialog')).toBe(null);
215+
});
216+
217+
fireEvent.pointerDown(trigger, { pointerType: 'touch' });
218+
fireEvent.click(trigger, { detail: 1 });
219+
220+
await waitFor(() => {
221+
expect(initialFocus).toHaveBeenLastCalledWith('touch');
222+
});
223+
});
224+
190225
it('should not move focus when initialFocus is false', async () => {
191226
function TestComponent() {
192227
return (

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

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
popupStoreSelectors,
99
PopupStoreState,
1010
PopupTriggerMap,
11+
setOpenTriggerState,
1112
usePopupStore,
1213
} from '../../utils/popups';
1314
import { type PreviewCardRoot } from '../root/PreviewCardRoot';
@@ -90,13 +91,7 @@ export class PreviewCardStore<Payload> extends ReactStore<
9091
updatedState.instantType = undefined;
9192
}
9293

93-
// If a popup is closing, the `trigger` may be null.
94-
// We want to keep the previous value so that exit animations are played and focus is returned correctly.
95-
const newTriggerId = eventDetails.trigger?.id ?? null;
96-
if (newTriggerId || nextOpen) {
97-
updatedState.activeTriggerId = newTriggerId;
98-
updatedState.activeTriggerElement = eventDetails.trigger ?? null;
99-
}
94+
setOpenTriggerState(updatedState, nextOpen, eventDetails.trigger);
10095

10196
this.update(updatedState);
10297
};

packages/react/src/tooltip/store/TooltipStore.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
popupStoreSelectors,
1212
PopupStoreState,
1313
PopupTriggerMap,
14+
setOpenTriggerState,
1415
usePopupStore,
1516
} from '../../utils/popups';
1617

@@ -103,13 +104,7 @@ export class TooltipStore<Payload> extends ReactStore<
103104
updatedState.instantType = undefined;
104105
}
105106

106-
// If a popup is closing, the `trigger` may be null.
107-
// We want to keep the previous value so that exit animations are played and focus is returned correctly.
108-
const newTriggerId = eventDetails.trigger?.id ?? null;
109-
if (newTriggerId || nextOpen) {
110-
updatedState.activeTriggerId = newTriggerId;
111-
updatedState.activeTriggerElement = eventDetails.trigger ?? null;
112-
}
107+
setOpenTriggerState(updatedState, nextOpen, eventDetails.trigger);
113108

114109
this.update(updatedState);
115110
};

packages/react/src/utils/popups/popupStoreUtils.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ export function setOpenTriggerState(
135135
* Sets up trigger data forwarding to the store.
136136
*
137137
* @param triggerId Id of the trigger.
138-
* @param triggerElement The trigger DOM element.
138+
* @param triggerElementRef Ref for the trigger DOM element.
139139
* @param store The Store instance managing the popup state.
140140
* @param stateUpdates An object with state updates to apply when the trigger is active.
141141
*/
@@ -198,8 +198,8 @@ export type PayloadChildRenderFunction<Payload> = (arg: {
198198

199199
/**
200200
* Ensures that when there's only one trigger element registered, it is set as the active trigger.
201-
* This allows controlled popups to work correctly without an explicit triggerId, maintaining compatibility
202-
* with the contained triggers.
201+
* This keeps triggerCount reactive while open and allows controlled popups to work correctly without
202+
* an explicit triggerId, maintaining compatibility with contained triggers.
203203
*
204204
* This should be called on the Root part.
205205
*

0 commit comments

Comments
 (0)