diff --git a/.changeset/a-dropdownmenu-no-longer-rewrites-its-trigger-s--83je.md b/.changeset/a-dropdownmenu-no-longer-rewrites-its-trigger-s--83je.md
new file mode 100644
index 0000000000000..bf2d4867a8654
--- /dev/null
+++ b/.changeset/a-dropdownmenu-no-longer-rewrites-its-trigger-s--83je.md
@@ -0,0 +1,6 @@
+---
+'@astryxdesign/core': patch
+---
+
+[fix] `DropdownMenu` no longer rewrites its trigger's style on every render. Its trigger refs, and the trigger ref `usePopover` returns, now stay the same function when the popover's `onShow` or `onHide` changes, so React stops detaching and reattaching the trigger, and a tooltip anchored to the same trigger keeps its place. Triggers that take `usePopover`'s ref directly (ComplexSelector's built-in trigger, DateTimeInput's date field and Breadcrumbs) get the stable ref too.
+@vjeux
diff --git a/packages/core/src/DropdownMenu/DropdownMenu.test.tsx b/packages/core/src/DropdownMenu/DropdownMenu.test.tsx
index e98af287724f3..2d9f2319dd6b8 100644
--- a/packages/core/src/DropdownMenu/DropdownMenu.test.tsx
+++ b/packages/core/src/DropdownMenu/DropdownMenu.test.tsx
@@ -30,6 +30,7 @@ import {DropdownMenuGroup} from './DropdownMenuGroup';
import {warmMenuBottomSheet} from './__tests__/warmMenuBottomSheet';
import {DropdownMenuSubMenu} from './DropdownMenuSubMenu';
import {Divider} from '../Divider';
+import {Tooltip} from '../Tooltip/Tooltip';
import {rtlStyles} from '../utils';
import {__resetInteractionModalityForTest} from '../utils/interactionModality';
import {focusOutlineStyles} from '../utils/focusOutline.stylex';
@@ -4379,3 +4380,119 @@ describe('DropdownMenu header and footer', () => {
).toBeTruthy();
});
});
+
+describe('DropdownMenu trigger refs across renders', () => {
+ // A ref that is a new function on every render makes React call it again on
+ // every commit (with null, then the element). Each call moved the menu's
+ // anchor off the trigger and back, rewriting the trigger's style and
+ // reordering its anchor names whenever a tooltip anchors there too.
+ function watchStyleWrites(element: HTMLElement) {
+ const records: MutationRecord[] = [];
+ const observer = new MutationObserver(found => records.push(...found));
+ observer.observe(element, {attributes: true, attributeFilter: ['style']});
+ return () => {
+ records.push(...observer.takeRecords());
+ observer.disconnect();
+ return records.length;
+ };
+ }
+
+ const anchorNames = (element: HTMLElement) =>
+ element.style.getPropertyValue('anchor-name');
+
+ it('hands renderTrigger the same ref on every render, so a render leaves the trigger and its tooltip anchor alone', () => {
+ const refs: DropdownMenuTriggerProps['ref'][] = [];
+ function Menu({count}: {count: number}) {
+ return (
+ {
+ refs.push(props.ref);
+ return (
+
+
+
+ );
+ }}>
+ {}} />
+
+ );
+ }
+ const {rerender} = render(
);
+ const trigger = screen.getByRole('button', {name: /^Menu/});
+ const before = anchorNames(trigger);
+ // The menu's anchor and the tooltip's, on one element.
+ expect(before.split(',')).toHaveLength(2);
+
+ const stopWatching = watchStyleWrites(trigger);
+ rerender();
+
+ expect(stopWatching()).toBe(0);
+ expect(anchorNames(trigger)).toBe(before);
+ expect(refs.length).toBeGreaterThan(1);
+ expect(refs.at(-1)).toBe(refs[0]);
+ });
+
+ it('leaves the default trigger and its tooltip anchor alone on a render', () => {
+ function Menu({count}: {count: number}) {
+ return (
+ <>
+
+ {}}>
+ {}} />
+
+ >
+ );
+ }
+ const {rerender} = render();
+ const trigger = screen.getByRole('button', {name: 'Actions'});
+ const before = anchorNames(trigger);
+ expect(before.split(',')).toHaveLength(2);
+
+ const stopWatching = watchStyleWrites(trigger);
+ rerender();
+
+ expect(stopWatching()).toBe(0);
+ expect(anchorNames(trigger)).toBe(before);
+ });
+
+ it('still hands the default trigger to a ref the caller passes', () => {
+ const buttonRef = createRef();
+ render(
+
+ {}} />
+ ,
+ );
+ expect(buttonRef.current).toBe(
+ screen.getByRole('button', {name: 'Actions'}),
+ );
+ });
+
+ it('hands the bottom-sheet trigger the same ref on every render', () => {
+ const refs: DropdownMenuTriggerProps['ref'][] = [];
+ function Menu({count}: {count: number}) {
+ return (
+ {
+ refs.push(props.ref);
+ return (
+
+ );
+ }}
+ />
+ );
+ }
+ const {rerender} = render();
+ rerender();
+
+ expect(refs.length).toBeGreaterThan(1);
+ expect(refs.at(-1)).toBe(refs[0]);
+ });
+});
diff --git a/packages/core/src/DropdownMenu/DropdownMenu.tsx b/packages/core/src/DropdownMenu/DropdownMenu.tsx
index a41b0cc377f76..a1282181e6c74 100644
--- a/packages/core/src/DropdownMenu/DropdownMenu.tsx
+++ b/packages/core/src/DropdownMenu/DropdownMenu.tsx
@@ -792,13 +792,15 @@ function DropdownMenuBottomSheet({
{currentTitle}
);
+ // A stable ref: an inline one re-runs on every render (null, then the element).
+ const sheetTriggerRef = useCallback((el: HTMLElement | null) => {
+ buttonRef.current = el;
+ }, []);
return (
<>
{renderTrigger != null ? (
renderTrigger({
- ref: el => {
- buttonRef.current = el;
- },
+ ref: sheetTriggerRef,
id: triggerId,
onClick: handleSheetTriggerClick,
onClickCapture: () => {},
@@ -1591,11 +1593,35 @@ function DropdownMenuPopover({
>
);
- const triggerProps: DropdownMenuTriggerProps = {
- ref: el => {
+ // Stable trigger refs. An inline ref is a new function every render, so React
+ // re-runs it on every commit (null, then the element), and each run moved the
+ // popover's anchor off the trigger and back: two inline-style writes on the
+ // trigger per render of the menu.
+ const popoverTriggerRef = popover.triggerRef;
+ const triggerRef = useCallback(
+ (el: HTMLElement | null) => {
buttonRef.current = el;
- popover.triggerRef(el);
+ popoverTriggerRef(el);
+ },
+ [popoverTriggerRef],
+ );
+ const consumerButtonRef = button.ref;
+ const buttonTriggerRef = useCallback(
+ (el: HTMLButtonElement | null) => {
+ triggerRef(el);
+ if (typeof consumerButtonRef === 'function') {
+ consumerButtonRef(el);
+ } else if (consumerButtonRef) {
+ /* eslint-disable react-compiler/react-compiler -- ref callback: forwarding consumer ref object */
+ consumerButtonRef.current = el;
+ /* eslint-enable react-compiler/react-compiler */
+ }
},
+ [triggerRef, consumerButtonRef],
+ );
+
+ const triggerProps: DropdownMenuTriggerProps = {
+ ref: triggerRef,
id: triggerId,
onClick: handleButtonClick,
onClickCapture: handleTriggerClickCapture,
@@ -1618,18 +1644,7 @@ function DropdownMenuPopover({
) : (