Skip to content

fix(HoverCard,Tooltip): wait for the trigger to have a layout box before showing - #5398

Open
HelloOjasMutreja wants to merge 2 commits into
meta:mainfrom
HelloOjasMutreja:fix/hovercard-controlled-open-anchoring
Open

HelloOjasMutreja wants to merge 2 commits into
meta:mainfrom
HelloOjasMutreja:fix/hovercard-controlled-open-anchoring

Conversation

@HelloOjasMutreja

@HelloOjasMutreja HelloOjasMutreja commented Aug 24, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #5374.

Problem

Dialog mounts its children before it opens: isOpen gates the <dialog>'s own showModal(), not whether {children} renders. So a HoverCard or Tooltip inside one with a controlled isOpen={true} (or isDefaultOpen) runs its show effect while the trigger sits in a still non-modal <dialog> and has no layout box (0x0).

A trigger with no box is not a valid CSS anchor. position-anchor resolves to its fallback position in the viewport corner, and unlike a live layout change that resolution is not revisited once the trigger later gets a box. I measured the card staying at (0, 4) for more than 3 seconds after the dialog visibly opened, so this is a permanently invalid anchor, not a one-frame race. A requestAnimationFrame defer and a setTimeout(50) defer both failed to fix it, which points at the missing box itself being the precondition.

Fix

All in useLayer's show(), the one place every context-mode open goes through:

  • A trigger that already has a box opens in the same task, so a menu or popover still opens on press and not on the next frame.
  • A boxless trigger waits first for a box, then for any running, finite animation on an ancestor to finish, so the layer anchors to the settled position and not a mid-transition one. Paused and infinite animations are not waited on.
  • The box wait races a ResizeObserver against an unbounded requestAnimationFrame poll of the trigger's own getBoundingClientRect(). A ResizeObserver delivers no entry at all for a purely display: inline box (a text-only trigger's wrapper <span>), so it cannot be the only signal. There is no time limit: the wait ends when the trigger gets a box or the open is cancelled.
  • If the trigger element is replaced while waiting, the wait restarts on the new element, and a trigger that unmounts for good stops the polling.
  • One cancel function covers both the box wait and the settle wait.
  • Without a layout engine to wait on (no ResizeObserver, no requestAnimationFrame, or no Web Animations API, as in jsdom) the layer opens immediately.

Separately, a transient null then same-element call on the trigger ref (what a combined ref utility makes when a handler changes identity) no longer removes and re-adds the element's anchor-name. That churn could also strand an open layer in the corner.

What changed after review

The first version waited at least a frame on every open, including for a trigger that already had a box. That moved a DropdownMenu's open from press to release, failed the Core/Layer viewport-inset stories (no open layer right after the click) and the DateInput and DateTimeInput FR10 suites (their no-op ResizeObserver stubs never ended the wait). It also had these gaps, all fixed here:

  • the settle wait is now limited to the boxless path, and an ancestor's paused animation can no longer hold a layer closed;
  • a wait polling an element that was then replaced never ended; it now restarts on the new trigger;
  • a synchronously firing ResizeObserver left the settle wait uncancellable by hide(), because the final cancel function overwrote its handle;
  • the test ResizeObserver mocks I had edited in Popover, PowerSearch and Tokenizer tests, the clearTimeouts hoist in useTooltip, and the Playwright glob are all reverted, since they only existed to accommodate the always-wait version. This PR no longer touches any of those files.

Verification

Reproduced against the exact story from the issue (Core/Layer Dismissal, Pinned Hover Card In Modal), where the Dialog opens later via a click.

Before, card stuck in the corner, disconnected from its trigger:
before

After, anchored above "Card trigger", matching placement="above":
after

Those two images are from the earlier version of this fix; the visible outcome for a boxless trigger is unchanged.

Tests (jsdom, with layout stubbed): eight new show() readiness tests in useLayer.test.tsx. I broke the code four ways to confirm each is caught: waiting on the box path again fails the same-task open test, dropping the running-only filter fails the paused-animation test, removing the trigger restart fails the replacement test, and a cancel that skips the settle wait fails the hide() test. The whole useLayer file passes (78 tests), as do the Layer, Popover, Tooltip, HoverCard, DropdownMenu, DateInput, DateTimeInput, DateRangeInput, PowerSearch, Tokenizer, Selector and MultiSelector suites, apart from one failure that is also on main. That one is layerViewportInset.test.ts, which compares Windows backslash paths to forward-slash names. Core typecheck, eslint and prettier are clean.

Limits

I could not run the browser parts here: the Storybook build and dev server crash on my machine with a native out-of-memory error. So the Core/Layer story play-guards, the anchorReadiness Playwright spec and the WebKit run are not verified by me, and the "after" image above is from the earlier version. The Core/Layer and DateInput FR10 failures from the last review should be gone by construction, because a trigger with a box now opens synchronously and jsdom without the Web Animations API no longer waits, but that is for CI to confirm.

@vercel

vercel Bot commented Aug 24, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
astryx Ready Ready Preview Oct 30, 2026 5:42pm UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Aug 24, 2026
@github-actions github-actions Bot added community Authored by a community contributor (not on the eng/design team) needs:code-review High-risk change (new package/component/API) — needs human code review before merge labels Aug 24, 2026
@github-actions

github-actions Bot commented Aug 24, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

📚 Storybook Preview

View Storybook for this PR

🧪 Sandbox Preview

View Sandbox for this PR

Component Size

Component Import before Import after Delta CSS before CSS after Delta
core/AlertDialog 56.9KB 57.5KB +659B 12.4KB 12.4KB 0B
core/AppShell 57.9KB 58.5KB +683B 11.6KB 11.6KB 0B
core/Avatar 37.5KB 38.1KB +685B 6.3KB 6.3KB 0B
core/AvatarGroup 39.8KB 40.5KB +707B 6.4KB 6.4KB 0B
core/AvatarGroupOverflow 39.9KB 40.6KB +682B 6.4KB 6.4KB 0B
core/Banner 47.8KB 48.5KB +656B 9.9KB 9.9KB 0B
core/BaseTypeahead 54.3KB 55.0KB +663B 9.9KB 9.9KB 0B
core/BreadcrumbItem 66.4KB 67.0KB +686B 11.0KB 11.0KB 0B
core/Button 43.3KB 43.9KB +668B 9.2KB 9.2KB 0B
core/Calendar 54.9KB 55.6KB +661B 10.0KB 10.0KB 0B
core/Carousel 50.2KB 50.8KB +657B 10.2KB 10.2KB 0B
core/ChatComposer 60.6KB 61.3KB +671B 10.8KB 10.8KB 0B
core/ChatComposerInput 55.7KB 56.4KB +646B 10.1KB 10.1KB 0B
core/ChatComposerTokenElement 55.7KB 56.4KB +646B 10.1KB 10.1KB 0B
core/ChatDictationButton 46.5KB 47.1KB +652B 9.5KB 9.5KB 0B
core/ChatLayout 48.8KB 49.5KB +652B 9.9KB 9.9KB 0B
core/ChatLayoutScrollButton 46.4KB 47.0KB +662B 9.6KB 9.6KB 0B
core/ChatMessageList 28.9KB 28.9KB 0B 7.8KB 7.8KB 0B
core/ChatSendButton 45.8KB 46.5KB +661B 9.2KB 9.2KB 0B
core/ChatToolCalls 31.9KB 31.9KB 0B 8.9KB 8.9KB 0B
core/CheckboxInput 49.9KB 50.6KB +673B 9.3KB 9.3KB 0B
core/CheckboxList 46.0KB 46.7KB +678B 8.1KB 8.1KB 0B
core/CheckboxListItem 54.0KB 54.7KB +687B 10.5KB 10.5KB 0B
core/CodeBlock 60.5KB 61.2KB +655B 10.7KB 10.7KB 0B
core/CommandPalette 50.9KB 50.9KB -1B 11.8KB 11.8KB 0B
core/CommandPaletteInput 32.3KB 32.3KB 0B 8.1KB 8.1KB 0B
core/ComplexSelector 55.8KB 56.5KB +685B 10.6KB 10.6KB 0B
core/ContextMenu 77.7KB 78.4KB +654B 14.6KB 14.6KB 0B
core/DateInput 99.9KB 100.5KB +629B 14.8KB 14.8KB 0B
core/DateRangeInput 69.4KB 70.0KB +696B 11.2KB 11.2KB 0B
core/DateTimeInput 110.1KB 110.7KB +652B 15.2KB 15.2KB 0B
core/DialogHeader 48.8KB 49.5KB +682B 9.8KB 9.8KB 0B
core/DrawerHeader 48.9KB 49.6KB +660B 11.2KB 11.2KB 0B
core/DropdownMenu 72.7KB 73.3KB +602B 14.7KB 14.7KB 0B
core/DropdownMenuSubMenu 58.1KB 58.8KB +698B 9.5KB 9.5KB 0B
core/Field 44.2KB 44.9KB +672B 8.0KB 8.0KB 0B
core/FieldLabel 39.8KB 40.4KB +644B 6.5KB 6.5KB 0B
core/FileInput 54.1KB 54.7KB +665B 10.3KB 10.3KB 0B
core/Heading 9.1KB 9.1KB 0B 6.8KB 6.8KB 0B
core/HoverCard 17.2KB 17.8KB +649B 4.9KB 4.9KB 0B
core/IconButton 43.3KB 44.0KB +670B 9.2KB 9.2KB 0B
core/InputGroup 44.8KB 45.5KB +679B 8.0KB 8.0KB 0B
core/LayerProvider 63.3KB 63.9KB +656B 10.1KB 10.1KB 0B
core/Lightbox 51.2KB 51.8KB +656B 9.8KB 9.8KB 0B
core/Link 43.5KB 44.2KB +678B 7.6KB 7.6KB 0B
core/ListItem 53.5KB 54.1KB +693B 10.5KB 10.5KB 0B
core/Markdown 144.5KB 145.1KB +655B 17.4KB 17.4KB 0B
core/MobileNav 51.0KB 51.7KB +662B 10.1KB 10.1KB 0B
core/MobileNavToggle 46.2KB 46.9KB +656B 9.4KB 9.4KB 0B
core/MoreMenu 72.9KB 73.5KB +630B 14.7KB 14.7KB 0B
core/MultiSelector 76.2KB 76.8KB +643B 14.6KB 14.6KB 0B
core/NumberInput 55.7KB 56.3KB +673B 10.7KB 10.7KB 0B
core/Pagination 87.7KB 88.3KB +602B 15.3KB 15.3KB 0B
core/Popover 49.8KB 50.5KB +646B 12.1KB 12.1KB 0B
core/PowerSearch 155.1KB 155.7KB +636B 18.6KB 18.6KB 0B
core/ProgressBar 7.7KB 7.7KB 0B 6.6KB 6.6KB 0B
core/RadioList 45.6KB 46.2KB +670B 8.0KB 8.0KB 0B
core/RadioListItem 52.1KB 52.8KB +692B 9.7KB 9.7KB 0B
core/SegmentedControl 38.8KB 39.5KB +713B 6.3KB 6.3KB 0B
core/Selector 79.1KB 79.8KB +652B 15.0KB 15.0KB 0B
core/SideNav 58.0KB 58.6KB +657B 10.6KB 10.6KB 0B
core/SideNavCollapseButton 46.6KB 47.3KB +656B 9.5KB 9.5KB 0B
core/SideNavHeading 54.7KB 55.4KB +686B 10.1KB 10.1KB 0B
core/SideNavItem 54.5KB 55.2KB +680B 10.1KB 10.1KB 0B
core/Slider 48.5KB 49.1KB +672B 8.5KB 8.5KB 0B
core/Spinner 26.3KB 26.3KB 0B 7.5KB 7.5KB 0B
core/StatusDot 19.0KB 19.7KB +690B 6.2KB 6.2KB 0B
core/Stepper 47.4KB 48.0KB +657B 9.6KB 9.6KB 0B
core/Switch 48.4KB 49.1KB +669B 9.3KB 9.3KB 0B
core/TabList 40.2KB 40.9KB +682B 7.2KB 7.2KB 0B
core/TabMenu 51.6KB 52.3KB +658B 10.0KB 10.0KB 0B
core/Table 84.0KB 84.6KB +651B 15.1KB 15.1KB 0B
core/TableCell 78.8KB 79.4KB +658B 14.8KB 14.8KB 0B
core/TableHeaderCell 78.6KB 79.2KB +659B 14.8KB 14.8KB 0B
core/TableSelectionToolbar 52.4KB 53.0KB +700B 11.2KB 11.2KB 0B
core/Text 8.6KB 8.6KB -1B 6.8KB 6.8KB 0B
core/TextArea 49.4KB 50.1KB +670B 9.4KB 9.4KB 0B
core/TextInput 53.5KB 54.2KB +668B 10.5KB 10.5KB 0B
core/Thumbnail 50.7KB 51.3KB +671B 11.0KB 11.0KB 0B
core/TimeInput 86.8KB 87.5KB +686B 13.8KB 13.8KB 0B
core/Timer 9.2KB 9.2KB 0B 6.8KB 6.8KB 0B
core/Timestamp 27.5KB 27.5KB 0B 9.5KB 9.5KB 0B
core/Toast 59.8KB 60.5KB +657B 9.7KB 9.7KB 0B
core/ToggleButton 44.1KB 44.8KB +648B 9.3KB 9.3KB 0B
core/Tokenizer 64.8KB 65.5KB +700B 11.7KB 11.7KB 0B
core/Toolbar 39.8KB 40.5KB +710B 7.7KB 7.7KB 0B
core/Tooltip 16.8KB 17.5KB +664B 4.9KB 4.9KB 0B
core/TopNav 51.9KB 52.5KB +648B 10.4KB 10.4KB 0B
core/TopNavHeading 54.2KB 54.8KB +688B 9.9KB 9.9KB 0B
core/TopNavMegaMenu 54.2KB 54.8KB +663B 10.8KB 10.8KB 0B
core/TopNavMenu 53.6KB 54.2KB +664B 10.1KB 10.1KB 0B
core/Typeahead 61.5KB 62.1KB +685B 11.3KB 11.3KB 0B

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 34.6KB 34.6KB 0B

Accessibility Audit

Status: No accessibility violations detected.

Visual Regression

55 of 500 shot(s) changed.

A change here is a question, not a failure: check whether the after is the
picture you intended. Record that review on the PR. Baseline maintenance is an
explicit dispatch of CI; this report never rewrites the baseline or adds a release gate.

component story theme mode pixels
Selector Indicator Space Bottom Sheet Narrow End RTL neutral light 753,933
Selector Indicator Space Bottom Sheet Wide Start neutral light 740,072
RichTextEditor Markdown parity: side by side probe dark 558,840
RichTextEditor Markdown parity: side by side probe light 540,756
Chat Full AI Chat probe light 230,823
Chat Full AI Chat probe dark 229,805
AppShell With Mobile Nav probe light 217,010
AppShell With Mobile Nav probe dark 216,960
Collapsible Dividers probe dark 66,445
Collapsible Dividers probe light 66,445
AppShell Playground neutral dark 65,198
AppShell Playground neutral light 63,788
Chat Default neutral dark 36,304
Chat Default neutral light 35,767
Chat Empty State probe light 23,531
Chat Empty State probe dark 22,622
ChatAutoScroll Scroll Behavior Comparison neutral dark 15,378
ChatAutoScroll Scroll Behavior Comparison neutral light 14,107
Chat With Attachments probe light 13,406
Chat With Attachments probe dark 11,232

and 35 more.


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

github-actions Bot added a commit that referenced this pull request Aug 24, 2026
@HelloOjasMutreja
HelloOjasMutreja marked this pull request as ready for review August 24, 2026 11:15
@HelloOjasMutreja
HelloOjasMutreja force-pushed the fix/hovercard-controlled-open-anchoring branch from a413d10 to 39af550 Compare August 27, 2026 07:40
github-actions Bot added a commit that referenced this pull request Aug 27, 2026
@HelloOjasMutreja
HelloOjasMutreja force-pushed the fix/hovercard-controlled-open-anchoring branch from 39af550 to 76bdb39 Compare September 7, 2026 03:28
github-actions Bot added a commit that referenced this pull request Sep 7, 2026

@cixzhang cixzhang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks—HoverCard now anchors correctly, but Tooltip still leaves the user’s hint detached at the viewport corner, and this head also makes the UI suite red.

  • packages/core/src/Layer/showWhenAnchored.ts:27 — In Chromium, the controlled Tooltip-in-Dialog story still opens at (0, 4) while its trigger is around (206, 298). The same exact-head run centers HoverCard correctly. Please keep trigger readiness in Layer’s existing lifecycle owner rather than adding a caller-level timing abstraction, and cover both controlled paths in a real-browser regression.
  • internal/test-utils/src/setup.ts:115 — This global ResizeObserver stub changes unrelated TabList behavior: the exact-head test calls scrollBy twice; current main passes 74/74, and the head also passes when only this setup change is removed. Please keep the test double local to the affected tests.

If you'd rather talk it through with someone, we're in Discord.

[Reviewed by Robohands]

@HelloOjasMutreja

Copy link
Copy Markdown
Contributor Author

Fixed in 6e4d9f5 and b695d9a.

Moved trigger-readiness into useLayer's own show(), the one place every context-mode open funnels through, and deleted showWhenAnchored.ts along with both callers now folded into it.

Tracing this live in Chromium turned up more than the box-readiness gap alone. HoverCard's own controlled-in-Dialog story was passing by coincidence, not because its readiness handling differed from Tooltip's: with the trigger's box confirmed present, show() would open correctly, and moments later a second render (caused by the story's own inline onOpenChange closure changing identity, unrelated to isOpen itself) would silently re-mount the SAME popover and trigger elements via React's ref-callback churn. addAnchorName/removeAnchorName's remove-then-readd on that churn flickered the trigger's anchor-name off and back on, and Chromium's position-try-fallbacks resolution does not get revisited once the anchor briefly disappears, permanently stranding the layer at its unanchored fallback position, exactly the reported "(0, 4)" symptom. Since addAnchorName is already idempotent, contextRef's null-branch no longer removes the name eagerly, only the branch handing off to a genuinely different element does.

Past that, real-Chromium tracing surfaced a second issue: even with a real box, showing a layer while an ancestor's own CSS entry animation (Dialog's scale/translate transition) is still running anchors against a mid-transform position that also never gets revisited, since a transform changes nothing ResizeObserver reports. show() now also waits, via document.getAnimations(), for any animation running on the trigger or an ancestor to finish before opening, checked one frame after the readiness gate so a just-triggered animation has actually started (confirmed directly against Chromium: checking synchronously always sees zero animations).

On the internal/test-utils/src/setup.ts point: sharedResizeObserver.ts already has the established pattern for the same gap (typeof ResizeObserver === 'undefined' guard, skip straight to the fallback), so useLayer's show() now mirrors it instead of depending on a global polyfill. The global stub is removed. A few test files that locally stub ResizeObserver for their own unrelated purposes (Popover, PowerSearch x2, Tokenizer) had a no-op observe() that never fired at all, which is what actually broke once useLayer started depending on that firing; those now fire once synchronously, matching the removed polyfill's own behavior, rather than the fix depending on a shared global.

Added packages/core/src/Layer/__tests__/anchorReadiness.chromium.spec.ts, covering both the Tooltip and HoverCard controlled-in-Dialog paths against a real Chromium build (jsdom can exercise neither the animation timing nor actual CSS anchor-positioning resolution this covers). Verified against the reverted code that it fails first (454px gap, the reported viewport-corner position) and passes cleanly across repeated runs with the fix.

Also verified: full ui Vitest project (10,058+ passing), explicit isolated runs of every touched suite (Layer, Tooltip, HoverCard, Popover, PowerSearch, PowerSearchValueEditor, Tokenizer, TabList) all green, typecheck and lint clean. The handful of failures that show up in a full-suite run (docPropLiterals, extensibleAxes, Resizable/utils import timeout, Table.perf, ContextMenu, a TabList roving-tabindex focus-timing test) are all confirmed pre-existing by reproducing them with this fix fully reverted back to the exact-head state.

github-actions Bot added a commit that referenced this pull request Sep 10, 2026
@HelloOjasMutreja
HelloOjasMutreja force-pushed the fix/hovercard-controlled-open-anchoring branch from b695d9a to 9ca8b36 Compare September 21, 2026 10:03
@HelloOjasMutreja

Copy link
Copy Markdown
Contributor Author

Rebased onto current main. One real conflict in playwright.config.ts: main's own pr-a11y work (#6115) added a *.a11y.browser.spec.ts glob to the same testMatch array this PR adds *.chromium.spec.ts to. Both patterns match real, distinct, currently-used spec files (BottomSheet's keyboard contract vs this PR's own Layer anchorReadiness spec), so kept both.

Verified after rebase: typecheck clean, and the affected suites (Popover, PowerSearch, Tokenizer, Layer, Tooltip, HoverCard) pass, isolated and in a narrower batch. A full combined run of all six directories intermittently worker-crashes or shows a single unrelated PowerSearchFilterEditor failure in this environment; reproduces identically on a clean, unmodified checkout of current main with the same broad batch, so it is local Vitest worker-pool flakiness under a large parallel run here, not something this PR introduced.

@HelloOjasMutreja

Copy link
Copy Markdown
Contributor Author

Checked the pr-a11y failure on this run: it's in Checkbox.a11y.chromium.spec.ts (a menu-item inventory mismatch, "Show archived" showing up as unexpectedly inert), a file this PR doesn't touch and unrelated to HoverCard/Tooltip anchoring. Not something this diff caused.

@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.

Thanks for moving anchor readiness into Layer and adding the real-Chromium Tooltip/HoverCard regression. A few current paths still need correction before this is ready:

  • show() waits for every trigger or ancestor animation to finish, so an infinitely pulsing trigger such as StatusDot can prevent its Tooltip from opening forever. Bound the wait to finite entry motion and cover a continuous-animation trigger.
  • The delayed path switches from getBoundingClientRect() to ResizeObserverEntry.contentRect. Text-only Tooltip/HoverCard triggers are inline spans, whose observed content box can remain empty, so a controlled/default-open layer can stay closed. Use a readiness signal that covers inline triggers and add that browser case.
  • contextRef(null) forgets the old trigger without removing this layer's anchor name. When an external anchor remains mounted or the ref moves, both old and new elements can keep the same association. Preserve transient same-element churn while cleaning a real detach/rebind.
  • The changeset still names the deleted showWhenAnchored helper; update it to describe the current Layer implementation.
  • The current Layer contract requires real Chromium and WebKit evidence for anchor-geometry changes, but this regression is Chromium-only. Add the equivalent WebKit proof.

[Automated review]

@HelloOjasMutreja
HelloOjasMutreja force-pushed the fix/hovercard-controlled-open-anchoring branch from 47f85e1 to b770b08 Compare October 5, 2026 21:21
@HelloOjasMutreja
HelloOjasMutreja force-pushed the fix/hovercard-controlled-open-anchoring branch from b770b08 to d554a15 Compare October 9, 2026 20:53
HelloOjasMutreja added a commit to HelloOjasMutreja/astryx that referenced this pull request Oct 9, 2026
…izeObserver

Two follow-ups from review:

- contextRef(null) dropped this layer's anchor name from the previous
  trigger immediately and discarded the stored ref, so a real detach was
  never cleaned up (a rebind through two different elements left both
  carrying the same anchor name; an unmounted-but-still-mounted-elsewhere
  trigger kept a dead name forever). The removal is now deferred to a
  microtask: cancelled if the same element reattaches before it runs
  (the original meta#5398 churn case this file already handled), applied if a
  different element takes over (immediate, synchronous) or nothing
  reattaches at all (a genuine detach). Added tests for all three shapes.

- The anchor-readiness wait in show() relied on ResizeObserver alone,
  which reliably never delivers any entry for a purely display:inline,
  non-replaced box -- exactly what a text-only Tooltip/HoverCard trigger's
  wrapper <span> is. A closed-then-open Dialog case with a text-only
  trigger (new story + browser spec case, passing on both Chromium and
  WebKit) reproduced this: the wait never resolved at all. show() now
  races a bounded requestAnimationFrame poll of the anchor's own
  getBoundingClientRect() alongside the ResizeObserver, so either signal
  can resolve it. The poll is bounded (ANCHOR_WAIT_MAX_FRAMES) so an
  environment with no real layout at all doesn't reschedule a frame
  forever under fake timers.
HelloOjasMutreja added a commit to HelloOjasMutreja/astryx that referenced this pull request Oct 15, 2026
…izeObserver

Two follow-ups from review:

- contextRef(null) dropped this layer's anchor name from the previous
  trigger immediately and discarded the stored ref, so a real detach was
  never cleaned up (a rebind through two different elements left both
  carrying the same anchor name; an unmounted-but-still-mounted-elsewhere
  trigger kept a dead name forever). The removal is now deferred to a
  microtask: cancelled if the same element reattaches before it runs
  (the original meta#5398 churn case this file already handled), applied if a
  different element takes over (immediate, synchronous) or nothing
  reattaches at all (a genuine detach). Added tests for all three shapes.

- The anchor-readiness wait in show() relied on ResizeObserver alone,
  which reliably never delivers any entry for a purely display:inline,
  non-replaced box -- exactly what a text-only Tooltip/HoverCard trigger's
  wrapper <span> is. A closed-then-open Dialog case with a text-only
  trigger (new story + browser spec case, passing on both Chromium and
  WebKit) reproduced this: the wait never resolved at all. show() now
  races a bounded requestAnimationFrame poll of the anchor's own
  getBoundingClientRect() alongside the ResizeObserver, so either signal
  can resolve it. The poll is bounded (ANCHOR_WAIT_MAX_FRAMES) so an
  environment with no real layout at all doesn't reschedule a frame
  forever under fake timers.
@HelloOjasMutreja
HelloOjasMutreja force-pushed the fix/hovercard-controlled-open-anchoring branch from d554a15 to 7819e65 Compare October 15, 2026 12:15

@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.

Thanks — the box wait fixes the reported case: with real motion, the pinned tooltip in a Dialog now sits above its trigger instead of in the corner. Changes are still needed, because show() now defers every context-mode open and this head turns the UI test and story play-guard checks red.

  • waitForAncestorAnimations also runs when the trigger already has a box, so every open in a browser waits at least a frame, a DropdownMenu waits for its own Button's press transition (with an ordinary click it opens on release instead of on press), and a finite animation paused on any ancestor keeps a layer closed — please open synchronously when the trigger has a box and limit any settle wait to the boxless path.
  • Because opening is no longer synchronous, the Core/Layer viewport-inset stories find no open layer right after the opening click, and the DateInput/DateTimeInput FR10 suites fail because their no-op ResizeObserver stubs never end the boxless wait.
  • A pending wait keeps polling the element captured when show() ran, so a boxless trigger that is replaced by another element never opens; please restart the wait on the new trigger and add a test.
  • Please drop the behavior-neutral clearTimeouts() hoist in useTooltip and the unused *.chromium.spec.ts glob, and refresh the PR description, which still describes the removed helper and global polyfill.

[Automated review]

Comment thread packages/core/src/Layer/useLayer.tsx Outdated
}
const rect = anchor.getBoundingClientRect();
if (rect.width !== 0 || rect.height !== 0) {
pendingAnchorWaitRef.current = waitForAncestorAnimations(anchor, openNow);

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 sends a trigger that already has a box through waitForAncestorAnimations, so every context-mode open now waits at least a frame and then for any finite animation on the trigger or its ancestors. In Chromium with motion on, a DropdownMenu opened with a 90 ms mouse press now opens only after release, once the trigger Button's own transform press transition is cancelled (about 100 ms after press-down, against about 10 ms before), and the Core/Layer viewport-inset stories no longer find the layer open right after the opening click. Could this branch call openNow() directly, keeping any settle wait for the boxless path?

Comment on lines +504 to +517
const running = document.getAnimations().filter(animation => {
const target =
animation.effect instanceof KeyframeEffect
? animation.effect.target
: null;
if (
!(target instanceof Node) ||
!(target === anchor || target.contains(anchor))
) {
return false;
}
const timing = animation.effect?.getComputedTiming();
return timing?.iterations !== Infinity;
});

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 keeps every finite animation, whatever it animates and whatever its play state: a trigger's color or press transition, a paused animation (its finished never settles while paused — in Chromium a layer under a paused ancestor animation never opened), or a scroll-driven one. If a settle wait stays, consider limiting it to running, document-timeline motion that can actually move the anchor.

Comment thread packages/core/src/Layer/useLayer.tsx Outdated
observer.observe(anchor);
if (!state.resolved) {
const poll = () => {
const box = anchor.getBoundingClientRect();

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 poll (and the observer at line 1039) stay bound to the element captured when show() ran. If the ref moves to another element while this wait is pending, contextRef moves the anchor name but nothing restarts the wait, so the layer never opens and this loop keeps measuring the old node every frame (a jsdom repro with mocked rects stays closed 0.5 s after the swap). Restarting the wait when a different element attaches, or reading triggerRef.current here, would fix it.

if (isOpen === undefined) {
return;
}
clearTimeouts();

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 hoist doesn't change behavior, but it changes an Effect whose comment doesn't name the external system it synchronizes, which the repo's runtime rules require for any changed Effect. Dropping the hunk keeps the PR focused on the fix.

Comment thread playwright.config.ts Outdated
'packages/*/src/**/*.a11y.browser.spec.ts',
// Real-layout regressions that are not accessibility-tree bindings (e.g.
// CSS anchor positioning), which jsdom cannot exercise.
'packages/*/src/**/*.chromium.spec.ts',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Every file this matches under packages/*/src is already matched by *.a11y.chromium.spec.ts, and the new spec is *.a11y.browser.spec.ts, so this glob is unused — can it go?

observe = vi.fn();
// Fires once synchronously on observe(), same as the real thing does
// for an already-visible element and as the jsdom-wide polyfill in
// internal/test-utils/src/setup.ts models it — useLayer's own show()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

There is no jsdom-wide ResizeObserver polyfill in internal/test-utils/src/setup.ts at this head, and a real observer never calls back synchronously inside observe() (it delivers at the next rendering update). The same wording is in the PowerSearch, PowerSearchValueEditor and Tokenizer stubs.

HelloOjasMutreja added a commit to HelloOjasMutreja/astryx that referenced this pull request Oct 17, 2026
…izeObserver

Two follow-ups from review:

- contextRef(null) dropped this layer's anchor name from the previous
  trigger immediately and discarded the stored ref, so a real detach was
  never cleaned up (a rebind through two different elements left both
  carrying the same anchor name; an unmounted-but-still-mounted-elsewhere
  trigger kept a dead name forever). The removal is now deferred to a
  microtask: cancelled if the same element reattaches before it runs
  (the original meta#5398 churn case this file already handled), applied if a
  different element takes over (immediate, synchronous) or nothing
  reattaches at all (a genuine detach). Added tests for all three shapes.

- The anchor-readiness wait in show() relied on ResizeObserver alone,
  which reliably never delivers any entry for a purely display:inline,
  non-replaced box -- exactly what a text-only Tooltip/HoverCard trigger's
  wrapper <span> is. A closed-then-open Dialog case with a text-only
  trigger (new story + browser spec case, passing on both Chromium and
  WebKit) reproduced this: the wait never resolved at all. show() now
  races a bounded requestAnimationFrame poll of the anchor's own
  getBoundingClientRect() alongside the ResizeObserver, so either signal
  can resolve it. The poll is bounded (ANCHOR_WAIT_MAX_FRAMES) so an
  environment with no real layout at all doesn't reschedule a frame
  forever under fake timers.
@HelloOjasMutreja
HelloOjasMutreja force-pushed the fix/hovercard-controlled-open-anchoring branch from 7819e65 to b557c8a Compare October 17, 2026 10:32
@HelloOjasMutreja

Copy link
Copy Markdown
Contributor Author

All four points addressed in b557c8a, and the description is rewritten to match the current code.

  • Synchronous open: show() now opens in the same task when the trigger already has a box. Only a boxless trigger waits, first for a box and then for running ancestor animations. A paused animation no longer holds a layer closed.
  • Suite failures: with the box path synchronous, the Core/Layer stories should find the open layer right after the click, and I skip the boxless wait when there is no Web Animations API, so the DateInput and DateTimeInput FR10 no-op ResizeObserver stubs no longer hold anything closed. I could not run the stories or the browser spec here (Storybook crashes on my machine), so please let CI confirm those.
  • Replaced trigger: a wait that was polling an element which gets replaced now restarts on the new one, and a trigger that unmounts for good stops the polling. Tests for both are in useLayer.test.tsx.
  • Cleanups: the useTooltip clearTimeouts hoist and the *.chromium.spec.ts glob are dropped. The Popover, PowerSearch and Tokenizer ResizeObserver mock edits are also reverted, since they only existed for the always-wait version.

I also found and fixed one more problem on the way: a synchronously firing ResizeObserver let the final cancel function overwrite the settle wait's handle, so hide() could not cancel a pending open. One cancel function now covers both stages, with a test.

I checked each new test by breaking the code the way it guards against, and each one fails. One unrelated test, layerViewportInset.test.ts, fails on Windows on plain main as well (backslash paths).

@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.

Thanks — waiting for a boxless trigger fixes the pinned HoverCard and the text-only and late-layout cases in Chromium and WebKit, but two problems still need fixing before this is ready:

  • PinnedTooltipInModal still paints "Pinned tip" in the viewport corner, and the first case of anchorReadiness.a11y.browser.spec.ts fails in Chromium (about 430 px against its 60 px bound) and WebKit (about 301 px), because Dialog displays its element during render but calls showModal() in a later Effect, so a show() in that commit sees a box and opens before the dialog is modal; a Tooltip whose isOpen turns true together with its Dialog does the same.
  • The trigger-swap restart in contextRef reads pendingAnchorElementRef, which is not cleared when the wait opens the layer or the browser closes it, and it ignores a hide() in the same commit, so a dismissed or hidden layer can reopen by itself; please clear that state, guard the restart, and add tests for both cases.

[Automated review]

Comment thread packages/core/src/Layer/useLayer.tsx Outdated
return;
}
const rect = anchor.getBoundingClientRect();
if (rect.width !== 0 || rect.height !== 0) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

In the commit that opens a Dialog, the dialog already has display: flex (applied during render), but showModal() has not run yet — Dialog calls it in a passive Effect, after its descendants' Effects. A controlled Tooltip whose show() runs in that commit — as in PinnedTooltipInModal, whose inline onOpenChange re-runs useTooltip's controlled Effect — measures a box here and opens before the dialog is modal; the tip then paints at (16, 16) in Chromium, as on base. Could the readiness check also wait while the trigger's closest <dialog> (or popover host) is not yet open?

// A show() is still waiting for a box on a different trigger: that wait
// polls the element it started with, so it would never end for this
// one. Restart it on the new trigger, after the commit that attached it.
const waitingOn = pendingAnchorElementRef.current;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

pendingAnchorElementRef is cleared only by cancelPendingAnchorWait(), so it still names the old trigger after the wait has opened the layer (line 1062) or the browser has closed it (handleToggle). A later swap then calls show() and reopens a layer the user dismissed, and the microtask below also opens after a hide() in the same commit. Clearing these refs when the wait completes or the layer closes, and checking a token that show() and hide() invalidate, would fix both.

if (triggerBox == null || tooltipBox == null) {
return;
}
expect(edgeGap(tooltipBox, triggerBox)).toBeLessThan(MAX_ANCHOR_GAP_PX);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Run against the CI-built Storybook for this head, this assertion fails in Chromium: Expected: < 60, Received: 429.84820176720524. Pull-request CI does not run *.a11y.browser.spec.ts, so nothing caught it.

@HelloOjasMutreja

Copy link
Copy Markdown
Contributor Author

Both problems are addressed in the latest push.

  • Dialog not yet modal: a trigger now counts as ready only when it has a box and any <dialog> around it is open. Dialog displays its element during render and calls showModal() in a later Effect, so in that window the trigger had a box and the layer opened before the dialog was modal. The wait now holds until the dialog is open, in both the ResizeObserver and the frame poll. A test with a boxed trigger inside a closed <dialog> checks it stays closed and opens once the dialog opens.
  • Trigger-swap restart: the pending-anchor state is cleared when the wait finishes and opens the layer. The queued restart is now cancelled by any hide() or show(), so a layer hidden in the same commit as the swap stays hidden. Tests cover both, and each fails without the change.

Not verified here: I cannot run Storybook or the browser specs on this machine (PinnedTooltipInModal and anchorReadiness.a11y.browser.spec.ts), so please let CI confirm the Chromium and WebKit cases. The Layer, Tooltip, HoverCard and Dialog unit suites pass; the one failure is layerViewportInset.test.ts, which also fails on plain main on Windows (backslash paths).

@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.

Thanks — the pinned Tooltip and HoverCard in a Dialog now anchor to their trigger in Chromium and WebKit, and the trigger-swap restart is fixed, but a few things still need changing before this can merge:

  • isAnchorReady() only counts a trigger as ready when its closest <dialog> is open, which a non-modal Drawer (hasScrim={false}, shown with showPopover()) never is, so the drawer's close-button tooltip, a DropdownMenu or a Popover inside it no longer open; please also accept a dialog that is showing as a popover, and add a test with a trigger inside a non-modal Drawer.
  • The unbounded requestAnimationFrame poll in show() conflicts with the current Layer runtime rule that "the runtime polls nothing" (docs/architecture/layer-runtime.md INV12, also docs/specs/AST-059-layer-viewport-inset/spec.md FR8); please replace it with observer- or event-driven signals.
  • The branch conflicts with main in contextRef, where main now moves an open layer's anchor observers to a replaced trigger, and CI has not run on this head; please rebase and keep both behaviors.

[Automated review]

Comment thread packages/core/src/Layer/useLayer.tsx Outdated
return false;
}
const dialog = anchor.closest('dialog');
return dialog == null || dialog.open;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

dialog.open stays false for a <dialog popover> shown with showPopover(), and the non-modal Drawer (hasScrim={false}) is one, so a trigger inside it never counts as ready: in Chromium and WebKit its close-button tooltip, a DropdownMenu and a Popover stay closed, and the frame poll runs until hide(). Counting a dialog that is showing as a popover as open (for example dialog.open || dialog.matches(':popover-open')), with a test that puts a trigger inside a non-modal Drawer, would fix it.

Comment thread packages/core/src/Layer/useLayer.tsx Outdated
Comment on lines +1116 to +1123
const poll = () => {
if (isAnchorReady(anchor)) {
resolve();
return;
}
state.rafHandle = requestAnimationFrame(poll);
};
state.rafHandle = requestAnimationFrame(poll);

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 loop polls every frame, which the current Layer runtime record rules out: docs/architecture/layer-runtime.md INV12 says "the runtime polls nothing" (as does docs/specs/AST-059-layer-viewport-inset/spec.md FR8). With PinnedTooltipInModal loaded and its Dialog still closed, it runs 60 callbacks a second in Chromium, against none on the base branch. An intersection observer on the trigger and a mutation observer on the dialog's open attribute could carry the inline-trigger and not-yet-modal cases instead.


`Dialog` mounts its children before it opens (`isOpen` gates `showModal()`, not rendering), so a `HoverCard` or `Tooltip` inside one with a controlled `isOpen={true}` runs its show effect while the trigger sits in a still non-modal `<dialog>` and has no box. A trigger with no box is not a valid CSS anchor: the popover resolves to its fallback position in the viewport corner, and unlike a live layout change that resolution is not revisited once the trigger later gets a box.

`Layer`'s `show()` now opens immediately when the trigger already has a box, so a menu or popover still opens in the same task as the press that asked for it. Only a boxless trigger waits: first for it to get a layout box, then for any running, finite animation on an ancestor to finish, so the layer anchors to the trigger's settled position and not to a mid-transition one. Paused animations and infinite ones are not waited on.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A trigger that has a box but sits in a <dialog> that is not open also waits now, so "Only a boxless trigger waits" no longer describes the change.

@HelloOjasMutreja

Copy link
Copy Markdown
Contributor Author

Reworked and rebased onto current main (one commit, 2741421):

  • No polling. The unbounded requestAnimationFrame poll is gone (INV12, AST-059 FR8). The box wait is driven by signals: ResizeObserver for a trigger gaining a box, IntersectionObserver for a purely display: inline trigger (the case the poll existed for, since ResizeObserver never delivers an entry for it), and the enclosing dialog's open attribute and toggle event. The only frame still scheduled is the one-shot settle read after the box exists. A new test asserts no frame is queued while the wait is pending.
  • Non-modal Drawer. A dialog now counts as showing when it is open or matches :popover-open, so a trigger inside a hasScrim={false} Drawer opens its layer. New test: a trigger inside a dialog that is shown as a popover opens on its toggle event.
  • Conflict with main. Kept both behaviors in contextRef: main's observer move to a replaced trigger while a layer is open, and the restart of a pending wait on the new trigger. A trigger that unmounts for good now also releases the open layer's anchor observers.

Verification: useLayer.test.tsx passes (104 tests) and typecheck is clean. The layerViewportInset.test.ts gutter test fails on Windows (backslash paths) on main as well. The Chromium and WebKit specs (anchorReadiness.a11y.browser.spec.ts, the pinned-Tooltip-in-modal story) cannot run on my machine, so I have not re-confirmed them against the observer-driven wait; CI will be the first run.

@HelloOjasMutreja

Copy link
Copy Markdown
Contributor Author

On the failing pr-a11y check: the failures are two MenuPressShadowRoot specs (a ContextMenu in a shadow root), which this change does not touch, and every recent open PR I checked (#8694 to #8705) shows pr-a11y failing as well, so it looks repo-wide rather than caused by this diff. The run also logged artifact digest-mismatch errors while downloading the Storybook build. I cannot re-run it without admin rights, so I will re-check after the next push.

@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.

Thanks — the pinned Tooltip and HoverCard in a Dialog now anchor to their trigger in Chromium and WebKit, layers inside a non-modal Drawer open again, and the wait no longer polls, but two things still need changing before this can merge:

  • isAnchorReady() treats any 0×0 trigger as boxless, so a trigger rendered with an empty box never opens: a ContextMenu in a shadow root without Astryx's styles stays closed on right-click, which is why pr-a11y fails both MenuPressShadowRoot ContextMenu tests on this head, and checking for no layout box at all (for example anchor.getClientRects().length === 0) keeps every case this PR fixes.
  • The SheetWithHoverTip story moves back to the deprecated BottomSheetSwitcher, undoing main's migration of it, so please restore main's version of that story.

[Automated review]

Comment thread packages/core/src/Layer/useLayer.tsx Outdated

function isAnchorReady(anchor: HTMLElement): boolean {
const rect = anchor.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A trigger that is rendered but has an empty box also lands here and never becomes ready: the ContextMenu's cursor anchor inside a shadow root without Astryx's styles is an empty inline <span> (one client rect, 0×0), so no observer or dialog signal ever reports it and the menu never opens. That is what fails the two MenuPressShadowRoot ContextMenu cases in pr-a11y on this head; base opens the menu. Testing for no layout box at all, for example anchor.getClientRects().length === 0 (which a display: none trigger and one inside a closed <dialog> both return), would keep every case this PR fixes.

</Tooltip>
</VStack>
</BottomSheet>
<BottomSheetSwitcher

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 brings back the deprecated BottomSheetSwitcher that main's #7648 migrated this story away from, and nothing in the anchoring fix needs it; the BottomSheetSwitcher record says stories teach the standalone BottomSheet replacement. Could this hunk and the import on line 33 go back to main's version?


`Dialog` mounts its children before it opens (`isOpen` gates `showModal()`, not rendering), so a `HoverCard` or `Tooltip` inside one with a controlled `isOpen={true}` runs its show effect while the trigger sits in a still non-modal `<dialog>` and has no box. A trigger with no box is not a valid CSS anchor: the popover resolves to its fallback position in the viewport corner, and unlike a live layout change that resolution is not revisited once the trigger later gets a box.

`Layer`'s `show()` now opens immediately when the trigger already has a box, so a menu or popover still opens in the same task as the press that asked for it. Only a boxless trigger waits: first for it to get a layout box, then for any running, finite animation on an ancestor to finish, so the layer anchors to the trigger's settled position and not to a mid-transition one. Paused animations and infinite ones are not waited on.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A trigger with a box inside a dialog that is not showing waits too (line 11 says so), so "Only a boxless trigger waits" undersells the change.

@HelloOjasMutreja
HelloOjasMutreja force-pushed the fix/hovercard-controlled-open-anchoring branch from 2741421 to ab380fb Compare October 30, 2026 17:34
@HelloOjasMutreja

Copy link
Copy Markdown
Contributor Author

Rebased onto main and pushed ab380fb with all three points:

  • Empty trigger box: isAnchorReady now asks for no layout box at all (anchor.getClientRects().length === 0: display: none, or inside a closed <dialog>) instead of a 0x0 getBoundingClientRect(), so an empty inline anchor such as the ContextMenu cursor <span> opens at once. A new useLayer test covers an empty 0x0 trigger, and fails with the old check; the existing readiness tests now mock getClientRects as well.
  • Story: SheetWithHoverTip and the BottomSheet import are main's version again, so the diff no longer touches the migrated BottomSheetSwitcher code; the PR's only change to LayerDismissal.stories.tsx is the added stories.
  • Changeset: it now says a trigger with no layout box (display: none, or inside a dialog that is not showing) waits, and that an empty box counts as a box.

I ran the built Storybook in Chromium: the three MenuPressShadowRoot specs (including the two ContextMenu cases that failed in pr-a11y) and the five anchorReadiness specs all pass, and the useLayer suite passes (105).

@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.

Thanks — at this head the pinned Tooltip and HoverCard in a Dialog anchor to their trigger in Chromium and WebKit, a ContextMenu whose cursor anchor has an empty box opens again, and the SheetWithHoverTip story is back to main's version, so this review found nothing left for you to change. Approval still waits on cixzhang's earlier change request, which stays open until cixzhang re-reviews this head or dismisses it; both of its points are now addressed. CI has not run on this head yet because it needs a maintainer's ci:run label.

[Automated review]

This branch was successfully deployed

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

Labels

CLA Signed This label is managed by the Meta Open Source bot. community Authored by a community contributor (not on the eng/design team) needs:code-review High-risk change (new package/component/API) — needs human code review before merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] A controlled (isOpen) HoverCard renders in the page corner, not anchored to its trigger

2 participants