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-components Tooltip + Menu for this one composition. Those use floating‑ui, which computes each popover's position independently and does not rely on a shared CSS anchor-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 nest Tooltip inside MenuTrigger on 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 (observed 0.2.0; logic unchanged on master)
- Source:
packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.ts
Summary
In
@fluentui/react-headless-components-preview,usePositioningwrites a single, uniqueanchor-nameonto 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 twousePositioninginstances clobber each other'sanchor-name. Only the last writer's popover stays anchored; the other points itsposition-anchorat 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
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:anchor-nameis a single-valued property here, but CSS allows a list. Two hooks targeting the same element each callsetProperty('anchor-name', …), so the second effect to run wins and the first popover is orphaned.Proof (rendered DOM, menu closed + tooltip visible)
Control (Tooltip alone) works —
button anchor-name = r0,tooltip position-anchor = r0(match).Proposed fix 1 — minimal (append to the
anchor-namelist)Make each
usePositioninginstance add its name to the trigger'sanchor-namelist and remove only its own on cleanup, instead of overwriting: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 uniqueposition-anchor, which now resolves because the trigger carries every name.A focused regression test would assert that, with two
usePositioninginstances on one element, the element'sanchor-namecontains both names.Proposed fix 2 — stretch (declarative intent via Interest Invokers)
Today
useTooltipdrives hover/focus entirely in JS (onPointerEnter/onFocus+showDelaytimer +showPopover()). The tooltip content already renders aspopover="hint". The web platform now offers a declarative primitive for exactly this — Interest Invokers (interestfor+interest-delay) — which the browser uses to open apopover=hinton hover/focus with no JS.Adopt it progressively in
library/src/components/Tooltip/useTooltip.ts:Feature gate:
@supports (interest-delay: 0s)/CSS.supports('interest-delay: 0s'). Setinterest-delay: 0son 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) andpopovertarget/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):
@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.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)packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.ts