Skip to content

fix(usePopover): its trigger ref stays the same function across renders - #9130

Merged
cixzhang merged 2 commits into
meta:mainfrom
vjeux:dropdown-stable-trigger-ref
Oct 31, 2026
Merged

cixzhang merged 2 commits into
meta:mainfrom
vjeux:dropdown-stable-trigger-ref

Conversation

@vjeux

@vjeux vjeux commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

User impact

A DropdownMenu whose trigger also has a tooltip (a custom trigger in Tooltip, or the default button's tooltip) rewrites that button's inline style on every render around it. The menu's anchor comes off and goes back, so anchor-name is rewritten and its two names swap order. A page that renders often never settles. We found it moving one app onto Astryx.

Problem and solution fit

  • DropdownMenu's trigger ref was a new function each render, so React called it again on every commit (null, then the element), moving the anchor each time.
  • Stabilizing those refs alone was not enough. They wrap usePopover's triggerRef, keyed on the whole layer object, which changes whenever onShow or onHide does; DropdownMenu's onShow changes with an inline renderTrigger, onClick or onOpenChange. That version still made 2 and 4 writes per render in the tests below.
  • So the fix is in the hook:
const layerRef = layer.ref;
const triggerRef = useCallback(
  (el: HTMLElement | null) => {
    triggerElementRef.current = el;
    layerRef(el);
  },
  [layerRef],
);

The callback reads nothing else from the layer, and layer.ref (useLayer's anchor callback, keyed on the anchor name and anchor-clearance option) reads only refs, so nothing goes stale.

Expected behavior and authority

Tooltip (#5951) and useHoverCard (#6606) got the same fix: a trigger ref keeps its identity when nothing about the trigger changes. No API or record changes.

Smallest restoration

  • usePopover: triggerRef is keyed on layer.ref instead of layer.
  • DropdownMenu: stable refs on its three trigger paths, a custom trigger, the default button (still forwarding the caller's ref) and the bottom-sheet trigger.
  • Triggers that take usePopover's ref directly get it stable too: ComplexSelector's built-in trigger, DateTimeInput's date field and Breadcrumbs.

Not chosen: stabilizing each DropdownMenu callback instead, leaving every other usePopover caller churning.

Also edited by open #7677 and #8550, both of which merge cleanly with this change.

Evidence

  • Before (main eae5d92): one parent render writes the trigger's style 2 times with a custom trigger in Tooltip, and 4 times with the default button's tooltip and an inline onOpenChange. The two anchor names swap order, and usePopover's triggerRef changes whenever onShow or onHide does.
  • After: 0 writes, the same order, the same ref. A caller's ref on the default button still gets the element.

Scope

  • One defect is restored.
  • Separable contradictory or unsettled tagalongs were removed or split.
  • No new public API, visual representation, default, or interaction model is hidden under the bug-fix label.
  • Public text and artifacts contain no internal Meta context.

Testing

How to check:

  1. Render <DropdownMenu button={{label: 'Actions', tooltip: 'More'}} onOpenChange={() => {}}> inside a component that renders every second.
  2. Watch the button's style attribute in the browser's Elements panel. Before: it flashes every second. After: it stays still.

The new cases in DropdownMenu.test.tsx and Popover.test.tsx count the trigger's style writes with a MutationObserver. They fail on main and pass with the fix.

Local gates at 08a5dc5:

  • The DropdownMenu, Popover, Tooltip and Layer suites, plus every other component that uses usePopover's trigger ref (Breadcrumbs, Chat, ComplexSelector, DateInput, DateRangeInput, DateTimeInput, MultiSelector): 2,171 tests pass. The 6 new cases fail on main.
  • The DropdownMenu, Tooltip and Popover Chromium specs: 217 passed.
  • Core typecheck, ESLint with no warnings, Prettier, pnpm check:repo and the Storybook build.
  • pnpm test:ui and pnpm test:node: the same failures as on main, none in these components.

usePopover built its trigger ref from the whole layer object, which changes
whenever the popover's onShow or onHide does. A caller that passes inline
callbacks got a new trigger ref on every render, so React detached and
reattached the trigger each time. The layer's anchor came off the trigger
and went back, two writes to its inline style per render, and with a tooltip
anchored to the same trigger the anchor names changed order.

The trigger ref is now keyed on the layer's own ref, which is stable, as
useHoverCard's refs were in meta#6606. DropdownMenu also hands its trigger
stable refs on all three trigger paths: a custom trigger, the default button
(still forwarding the caller's ref), and the bottom-sheet trigger. Its own
inline refs churned the same way.
@vercel

vercel Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Error Error Oct 31, 2026 12:11pm UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 31, 2026
@astracat-bot

astracat-bot Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Oct 31, 2026, 12:18 PM UTC

@github-actions

github-actions Bot commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

Preview: the links below open this branch's latest Vercel deployment, which can trail a new push until that push's deployment is ready.

📚 Storybook Preview

View Storybook for this PR

🧪 Sandbox Preview

View Sandbox for this PR

Modified Components

Popover (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Entry File (ESM) 412B 412B 0B
Lines of Code 986 987 -
Complexity Very High Very High (147) -

Component Size

Component Import before Import after Delta CSS before CSS after Delta
core/BaseTypeahead 56.8KB 56.8KB +13B 10.2KB 10.2KB 0B
core/BreadcrumbItem 69.6KB 69.6KB +3B 11.4KB 11.4KB 0B
core/ChatComposer 63.3KB 63.3KB +6B 11.1KB 11.1KB 0B
core/ChatComposerInput 58.0KB 58.1KB +9B 10.3KB 10.3KB 0B
core/ChatComposerTokenElement 58.0KB 58.1KB +11B 10.3KB 10.3KB 0B
core/ComplexSelector 58.9KB 58.9KB -3B 11.0KB 11.0KB 0B
core/ContextMenuItem 55.3KB 55.3KB 0B 9.9KB 9.9KB 0B
core/DateInput 102.9KB 102.9KB +17B 15.1KB 15.1KB 0B
core/DateRangeInput 72.3KB 72.3KB +3B 11.5KB 11.5KB 0B
core/DateTimeInput 114.3KB 114.3KB +8B 15.5KB 15.5KB 0B
core/DropdownMenu 75.4KB 75.3KB -18B 15.3KB 15.3KB 0B
core/DropdownMenuCheckboxItem 17.6KB 17.6KB 0B 5.9KB 5.9KB 0B
core/DropdownMenuDivider 5.2KB 5.2KB 0B 548B 548B 0B
core/DropdownMenuGroup 6.2KB 6.2KB 0B 3.4KB 3.4KB 0B
core/DropdownMenuItem 16.8KB 16.8KB 0B 5.5KB 5.5KB 0B
core/DropdownMenuRadioGroup 6.1KB 6.1KB 0B 3.3KB 3.3KB 0B
core/DropdownMenuRadioItem 17.6KB 17.6KB 0B 5.9KB 5.9KB 0B
core/DropdownMenuSubMenu 60.9KB 60.9KB 0B 9.9KB 9.9KB 0B
core/MoreMenu 75.7KB 75.6KB -38B 15.3KB 15.3KB 0B
core/MultiSelector 75.3KB 75.3KB +2B 15.0KB 15.0KB 0B
core/Pagination 87.8KB 87.9KB +4B 15.7KB 15.7KB 0B
core/Popover 51.9KB 51.9KB +14B 12.4KB 12.4KB 0B
core/PowerSearch 171.2KB 171.2KB +13B 20.3KB 20.3KB 0B
core/Selector 79.4KB 79.4KB +3B 15.4KB 15.4KB 0B
core/SideNavHeading 60.9KB 60.9KB +10B 10.5KB 10.5KB 0B
core/SideNavItem 57.0KB 57.0KB +4B 10.4KB 10.4KB 0B
core/TabMenu 57.6KB 57.6KB +10B 10.4KB 10.4KB 0B
core/Tokenizer 71.1KB 71.1KB +6B 16.9KB 16.9KB 0B
core/TopNavHeading 60.3KB 60.3KB +5B 10.3KB 10.3KB 0B
core/TopNavMegaMenu 56.7KB 56.7KB +1B 11.1KB 11.1KB 0B
core/TopNavMenu 59.9KB 59.9KB +7B 10.5KB 10.5KB 0B
core/Typeahead 64.0KB 64.1KB +9B 11.6KB 11.6KB 0B
lab/ChatEmojiPicker N/A 67.9KB N/A N/A 773B N/A
lab/ChatReactionBar N/A 68.5KB N/A N/A 1.1KB N/A
lab/CommentComposer N/A 97.1KB N/A N/A 1.9KB N/A
lab/CommentThread N/A 220.0KB N/A N/A 2.4KB N/A
lab/Comments N/A 224.6KB N/A N/A 2.5KB N/A
lab/MobilePowerSearch N/A 157.9KB N/A N/A 1.2KB N/A
lab/TourStep N/A 57.3KB N/A N/A 590B N/A
lab/TransferListSelector N/A 75.5KB N/A N/A 1.9KB N/A
richtext/RichTextEditorToolbar N/A 215.8KB N/A N/A 454B N/A

Sizes are gzip. Import counts the JavaScript the import loads eagerly; CSS counts the package stylesheet rules referenced by the modules it can load from its own package, lazily loaded ones included.

Bundle Size Summary

Gzip figures measure each package's entry file, not the code an import pulls in.

Package Entry (ESM) Entry gzip before Entry gzip after Delta
@astryxdesign/core 4.9KB 1.2KB 1.2KB 0B
Package stylesheet Gzip before Gzip after Delta
@astryxdesign/core 36.0KB 36.0KB 0B

Accessibility Audit

Status: The accessibility audit did not run on this pull request. It runs after merges to main and before each release; pull requests that touch components run it with the ci:heavy-lanes label.


Generated by PR Enrichment workflow | Storybook | Sandbox | View full report

@cixzhang cixzhang added the ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) label Oct 31, 2026
@cixzhang
cixzhang marked this pull request as ready for review October 31, 2026 11:43

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The usePopover and DropdownMenu fix looks right: the new tests fail without it and pass with it, and the related component suites pass. One change is needed before this lands: the changeset says DateInput, DateRangeInput, MultiSelector and ChatComposerInput's trigger menu are fixed too, but they are not.

  • DateInput, DateRangeInput and MultiSelector wrap the trigger ref in their own inline arrow, so React still detaches and reattaches their triggers on every render (so does ComplexSelector's renderTrigger path).
  • ChatComposerInput's trigger menu calls popover.triggerRef directly instead of passing it as a ref, so this change does not affect it.

Please list only the triggers that take usePopover's ref directly, or stabilize those wrapper refs in this pull request.

[Automated review]

'@astryxdesign/core': patch
---

[fix] `usePopover`'s trigger ref now stays the same function when the popover's `onShow` or `onHide` changes, and `DropdownMenu`'s own trigger refs are stable too. A trigger that takes it (DropdownMenu, Breadcrumbs, ComplexSelector, DateInput, DateRangeInput, DateTimeInput, MultiSelector and the trigger menu of `ChatComposerInput`) is no longer detached and reattached on renders that pass new callbacks, so its style is not rewritten each time and a tooltip anchored to the same trigger keeps its place.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This list goes further than the change does. DateInput (DateInput.tsx:838), DateRangeInput (DateRangeInput.tsx:697) and MultiSelector (MultiSelector.tsx:2580) wrap popover.triggerRef in an inline arrow, so React still detaches and reattaches their triggers on every render; ComplexSelector's renderTrigger path (ComplexSelector.tsx:781) does the same. ChatComposerInput's trigger menu calls the ref directly and never passes it to React. Suggest naming only DropdownMenu, ComplexSelector's built-in trigger, DateTimeInput's date field and Breadcrumbs, or stabilizing those wrappers here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Narrowed in 08a5dc5: the changeset and description now list only the triggers that take the ref directly.

… reaches

DateInput, DateRangeInput, MultiSelector and ComplexSelector's custom trigger
wrap the trigger ref in an inline function of their own, and the composer's
trigger menu calls it directly instead of passing it as a ref, so this change
does not reach them. The changeset now names DropdownMenu and the triggers
that take usePopover's ref directly.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The usePopover and DropdownMenu trigger-ref fix is ready to land: the new tests fail without it and pass with it, and the related component suites pass. The narrowed changeset now matches what the change reaches, so nothing more is needed here.

[Automated review]

@cixzhang
cixzhang merged commit 8bc9ab9 into meta:main Oct 31, 2026
53 of 54 checks passed
@vjeux
vjeux deleted the dropdown-stable-trigger-ref branch October 31, 2026 12:25

This branch had an error being deployed

1 failed deployment
Preview — 08a5dc53 Deployed Oct 31, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants