Skip to content

[react-headless-components-preview] usePositioning overwrites anchor-name → Tooltip+Menu on one trigger breaks positioning #36346

Description

@micahgodbolt

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):

  1. 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.
  2. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions