microsoft / microsoft/fluentui
[react-headless-components-preview] usePositioning overwrites anchor-name → Tooltip+Menu on one trigger breaks positioning
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 20.3k
- Forks
- 2.9k
- Avg merge
- 2d 9h
- Merged PRs (30d)
- 46
Description
Summary
In @fluentui/react-headless-components-preview, usePositioning writes a single, unique anchor-name onto the trigger element and overwrites any value already there. When two positioned popovers share one trigger — e.g. a hover Tooltip + a click Menu on the same button — the two usePositioning instances clobber each other's anchor-name. Only the last writer's popover stays anchored; the other points its position-anchor at a name that no longer exists and falls back to default (static / top‑left) placement.
This blocks a common pattern (icon-only "more actions" buttons that need both a label tooltip and a menu).
Repro
<Menu>
<MenuTrigger>
<Tooltip content="More actions" relationship="label">
<Button icon={<MoreHorizontalRegular />} />
</Tooltip>
</MenuTrigger>
<MenuPopover><MenuList>{/* … */}</MenuList></MenuPopover>
</Menu>
Hover the button → the tooltip appears detached from the trigger (top‑left of the viewport) instead of beside it. The menu (click) positions correctly.
Root cause
library/src/hooks/usePositioning/usePositioning.ts:
// L50
const anchorName = `--${useId('popover-anchor-')}`; // unique per hook instance
// L71–80
useIsomorphicLayoutEffect(() => {
if (!effectiveTarget) return;
effectiveTarget.style.setProperty('anchor-name', anchorName); // ← OVERWRITES
return () => {
effectiveTarget.style.removeProperty('anchor-name'); // ← REMOVES ALL
};
}, [effectiveTarget, anchorName]);
// L105
node.style.setProperty('position-anchor', anchorName); // popover side
anchor-name is a single-valued property here, but CSS allows a list. Two hooks targeting the same element each call setProperty('anchor-name', …), so the second effect to run wins and the first popover is orphaned.
Proof (rendered DOM, menu closed + tooltip visible)
button anchor-name = "--popover-anchor-r1" ← only the MENU's name survives
div[role=tooltip popover=hint] position-anchor = "--popover-anchor-r2" ← orphaned (r2 unmapped)
div[role=presentation popover=auto] position-anchor = "--popover-anchor-r1" ← menu, OK
Control (Tooltip alone) works — button anchor-name = r0, tooltip position-anchor = r0 (match).
Proposed fix 1 — minimal (append to the anchor-name list)
Make each usePositioning instance add its name to the trigger's anchor-name list and remove only its own on cleanup, instead of overwriting:
useIsomorphicLayoutEffect(() => {
if (!effectiveTarget) return;
const read = () =>
effectiveTarget.style
.getPropertyValue('anchor-name')
.split(',').map(s => s.trim()).filter(Boolean);
const next = read();
if (!next.includes(anchorName)) {
next.push(anchorName);
effectiveTarget.style.setProperty('anchor-name', next.join(', '));
}
return () => {
const remaining = read().filter(n => n !== anchorName);
if (remaining.length) {
effectiveTarget.style.setProperty('anchor-name', remaining.join(', '));
} else {
effectiveTarget.style.removeProperty('anchor-name');
}
};
}, [effectiveTarget, anchorName]);
This preserves any app-authored anchor-name, supports N popovers per trigger, and is fully backward compatible (single-popover triggers are unchanged). Each popover keeps its own unique position-anchor, which now resolves because the trigger carries every name.
A focused regression test would assert that, with two usePositioning instances on one element, the element's anchor-name contains both names.
Proposed fix 2 — stretch (declarative intent via Interest Invokers)
Today useTooltip drives hover/focus entirely in JS (onPointerEnter/onFocus + showDelay timer + showPopover()). The tooltip content already renders as popover="hint". The web platform now offers a declarative primitive for exactly this — Interest Invokers (interestfor + interest-delay) — which the browser uses to open a popover=hint on hover/focus with no JS.
Adopt it progressively in library/src/components/Tooltip/useTooltip.ts:
const supportsInterest =
typeof CSS !== 'undefined' && CSS.supports?.('interest-delay: 0s');
state.children = applyTriggerPropsToChildren(children, {
...triggerAriaProps,
...child?.props,
ref,
// Declarative intent where supported; points at the popover=hint content
interestfor: state.content.id,
// Keep the JS hover/focus engine ONLY as a fallback (avoids double-fire)
...(supportsInterest ? {} : {
onPointerEnter: /* existing */,
onPointerLeave: /* existing */,
onFocus: /* existing */,
onBlur: /* existing */,
}),
});
Feature gate: @supports (interest-delay: 0s) / CSS.supports('interest-delay: 0s'). Set interest-delay: 0s on the content to match the existing JS timing.
Benefits: zero JS hover engine in modern browsers, native top‑layer + accessibility semantics, and the existing JS path remains as graceful degradation. Combined with fix 1, a single trigger can carry interestfor (tooltip) and popovertarget/menu click on one shared anchor — fully declarative.
Current workaround for consumers (until fix 1 ships)
For teams hitting this now (icon button needing tooltip and menu):
- Preferred — use the stable
@fluentui/react-componentsTooltip + Menu for this one composition. Those use floating‑ui, which computes each popover's position independently and does not rely on a shared CSSanchor-name, so there is no collision. Swap back to the headless/modern components once fix 1 lands. - If you must stay on the headless components, give the tooltip and the menu distinct trigger elements so their
anchor-names don't collide (e.g. don't nestTooltipinsideMenuTriggeron the same button — anchor the tooltip to a wrapping element and the menu to the button). Visually identical, but each popover anchors to a different node.
Both are temporary; fix 1 removes the need for either.
Environment
@fluentui/react-headless-components-preview(observed0.2.0; logic unchanged onmaster)- Source:
packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.ts
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.ts and reproduce the Tooltip-plus-Menu composition from the issue. Trace the anchor-name effect and cleanup, then add or locate a focused regression test covering two usePositioning instances on one trigger. Done means both popovers retain valid positioning anchors without removing unrelated names.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- react, typescript
- Domain
- frontend
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 68/100