Hacktoberfest 2026: những issue maintainer đã đánh dấu cho tháng Mười, đang mở và phù hợp người mới. Xem issue Hacktoberfest

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

Đang mở
#36,346 0 bình luận 0 reaction 0 người được giao Xem trên GitHub

Maintainer thường phản hồi trong vòng 1 ngày

Chưa có ai nhận issue này.

Đánh giá

Độ khó
3/5
Thời gian dự kiến
1-2 ngày
Mức phù hợp với người mới
68/100
Loại issue
Lỗi
Độ rõ ràng
Khá rõ ràng
Mức độ hoạt động
Ít trao đổi
Công nghệ
react, typescript
Lĩnh vực
frontend

Hướng nghiên cứu

Bắt đầu trong packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.ts và tái hiện composition Tooltip-plus-Menu từ issue. Theo dõi effect và cleanup của anchor-name, sau đó thêm hoặc tìm một regression test tập trung bao quát hai instance usePositioning trên cùng một trigger. Hoàn tất khi cả hai popover vẫn giữ các positioning anchor hợp lệ mà không xóa các tên không liên quan.

Do mô hình lập chỉ mục viết ra từ nội dung của issue.

Mô tả

Fluent UI react-components (v9) From Shield Needs: Investigation Type: Bug :bug:

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
Ngôn ngữ chính
TypeScript
Star
20.3k
Fork
2.9k
Merge trung bình
2 ngày 7 giờ
Pull request đã merge (30 ngày)
69

Chuẩn bị môi trường

Mở trong Codespaces

Khởi chạy dev container của dự án ngay trên trình duyệt, bằng tài khoản GitHub của bạn.

  • Không có Dockerfile hay tệp Docker Compose
  • Có mẫu pull request
  • Không có hướng dẫn đóng góp

Bắt đầu từ đâu

  1. Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
  2. Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
  3. Fork repository và làm thay đổi trên một nhánh.
  4. Mở pull request có tham chiếu số hiệu của issue.

Issue khác của microsoft/fluentui

Tất cả issue của microsoft/fluentui

Issue tương tự

Thêm issue về TypeScript

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.