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

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions