Skip to content

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

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

HelloOjasMutreja wants to merge 5 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 is driven by signals, never by polling. It re-reads whether the anchor is ready on a ResizeObserver entry (a block-level trigger gaining a box), on an IntersectionObserver report (which reaches a display: inline trigger, such as a text-only trigger's wrapper <span>, where a ResizeObserver delivers no entry), and on the enclosing <dialog>'s open attribute and toggle event. These use Core's shared observers, and each registration is released on its own. 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 wait and releases its observers.
  • 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 on 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.

Since the last review the unbounded requestAnimationFrame poll is gone (it is replaced by the signals above), the branch is rebased on current main, and the new comments and test harness pass ASTRYX_STRICT_LINT=1 pnpm lint.

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 Nov 2, 2026 8:18pm 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

🔎 Preview · b4bad83

This branch's preview address cannot be known in advance; open it from Vercel's comment.

Current run: The current CI run concluded failure. Current analysis is unavailable.


Generated by PR Enrichment workflow | View current CI run

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

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]

@HelloOjasMutreja

Copy link
Copy Markdown
Contributor Author

The last bot review found nothing left to change on this head, and main is merged in again (the Layer code moved into popoverOperations.ts, so I re-applied the anchor-ready change on top of it). CI here stops at the ci:run gate, so lint, test, build and docsite-test have not run on this head. Could a maintainer add the ci:run label? Locally the Layer suite and the typecheck pass, apart from layerViewportInset.test.ts, which fails on Windows path separators on main too.

@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 controlled Tooltip and HoverCard in a Dialog anchor to their trigger in Chromium and WebKit, and a rendered trigger still opens in the same task, but one change is still needed. The anchor wait in useLayer's show() creates its own ResizeObserver (line 1645) and MutationObserver (line 1652), while Core already owns both concerns in observeResize (utils/sharedResizeObserver.ts) and observeMutations (utils/sharedMutationObserver.ts), which architecture:react-component-runtime INV16 requires callers to use. Please route the wait through those two utilities (the IntersectionObserver and the dialog toggle listener can stay), check the useLayer tests that count ResizeObserver instances, and refresh the description, which still describes the removed requestAnimationFrame poll. Merging will also need cixzhang's earlier change request to be re-reviewed or dismissed; both of its points hold at this head.

[Automated review]

Comment thread packages/core/src/Layer/useLayer.tsx Outdated
Comment on lines +1645 to +1653
const observer = new ResizeObserver(check);
const intersection =
typeof IntersectionObserver === 'undefined'
? null
: new IntersectionObserver(check);
const mutations =
dialog && typeof MutationObserver !== 'undefined'
? new MutationObserver(check)
: null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Core already owns both of these concerns: observeResize (utils/sharedResizeObserver.ts) and observeMutations (utils/sharedMutationObserver.ts), and architecture:react-component-runtime INV16 asks callers to use them rather than build their own observer. Something like const stopResize = observeResize(anchor, check) and const stopDialog = dialog ? observeMutations(dialog, {attributes: true, attributeFilter: ['open']}, check) : null, both called from release(), keeps one observation path. observeResize calls check once synchronously, which is harmless here. The IntersectionObserver and the toggle listener can stay.

@HelloOjasMutreja

Copy link
Copy Markdown
Contributor Author

Thanks, routed. The anchor wait in show() now uses observeResize and observeMutations (8c83f74) instead of its own ResizeObserver and MutationObserver. The IntersectionObserver and the dialog toggle listener stay, as you suggested. The unsubscribes only drop this registration, and a wait that resolves during registration is released right after it.

Checked locally: the Layer, Tooltip and HoverCard suites pass (380 of 381; the one failure is layerViewportInset.test.ts, a Windows path-separator problem that also fails on main and is not in this diff), plus core typecheck, eslint and prettier. I did not re-run the Chromium and WebKit anchoring check against this head, so that part rests on the unit tests, which cover the wait path with resize stubs.

…n observers

The anchor wait in show() created its own ResizeObserver and MutationObserver. Route it through observeResize and observeMutations, which architecture:react-component-runtime INV16 requires callers to use. The IntersectionObserver and the dialog toggle listener stay.
The harness attached the trigger with an inline ref, which detaches and
reattaches it on every render. Each detach now defers its anchor-name removal
by a microtask, so the replay test, which holds the ledger's microtasks by
hand, held those too. A stable ref leaves it holding only the ledger's.
@HelloOjasMutreja
HelloOjasMutreja force-pushed the fix/hovercard-controlled-open-anchoring branch from 8c83f74 to e2d6bf0 Compare November 2, 2026 16:54
@HelloOjasMutreja

Copy link
Copy Markdown
Contributor Author

Rebased onto main: the Layer code had moved again (the popover-operation helpers are in popoverOperations.ts now, and context layers mount lazily), so I re-applied the anchor-ready change and the shared-observer routing on top of that, head e2d6bf0.

One test change outside my own: the focus-return harness in useLayer.test.tsx attached its trigger with an inline ref, which detaches and reattaches it on every render. This change defers the anchor-name removal of a detach by a microtask, so the "replay the browser refuses again" test, which holds the ledger's microtasks by hand, also held those. I gave the harness a stable ref; no assertion changed.

The Layer, Tooltip and HoverCard suites pass (495 of 496; the one failure is layerViewportInset.test.ts, a Windows path-separator problem that also fails on main), plus the core typecheck. I did not re-run the Chromium and WebKit anchoring check against this head. CI here still stops at the ci:run gate.

@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 controlled Tooltip and HoverCard in a Dialog anchor to their trigger in Chromium and WebKit, and the wait now uses Core's shared observers. One change is still needed: the branch fails the repository's strict lint, which CI runs once the ci:run label is added, with 8 errors from ASTRYX_STRICT_LINT=1 pnpm lint:

  • useLayer.test.tsx line 1229: the new useCallback reads popover and tooltip but lists only their refs; destructure the two refs into locals and list those.
  • useLayer.tsx lines 895, 1638, 1647, 1660, 1767 and 1800: the new comments cite an issue number or a spec claim (#5398, INV12, INV16, INV6); keeping only the reason also clears the two older sites the lint then reports at lines 1828 and 2121.

The description still describes the removed requestAnimationFrame poll, so a refresh would help reviewers, and merging will also need cixzhang's earlier change request to be re-reviewed or dismissed; both of its points hold at this head.

[Automated review]

popover.ref(el);
tooltip.ref(el);
},
[popover.ref, tooltip.ref],

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Strict lint, which CI runs, fails here: @eslint-react/exhaustive-deps reports that this useCallback reads popover and tooltip. Destructuring the refs keeps the callback stable and satisfies the rule:

const {ref: popoverRef} = popover;
const {ref: tooltipRef} = tooltip;
const triggerRef = useCallback(
  (el: HTMLButtonElement | null) => {
    popoverRef(el);
    tooltipRef(el);
  },
  [popoverRef, tooltipRef],
);

Comment thread packages/core/src/Layer/useLayer.tsx Outdated
openNow();
return;
}
// The wait is driven by signals, never by polling (INV12: the runtime

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Strict lint fails on the provenance in the new comments: #5398 at lines 895, 1647 and 1767, INV12 here, INV16 at line 1660 and INV6 at line 1800. These, and this 18-line block that cites a claim, use up the file's counted baseline, so the older sites at lines 1828 and 2121 report too. Keeping only the reason in each comment clears all eight errors.

* a later Effect, so in that window the trigger already has a box while the
* dialog is not yet in the top layer; opening a layer then paints it before
* the dialog is modal.
*/

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-blocking: the JSDoc above describes isAnchorReady but sits on isShownAsPopover. Moving it to isAnchorReady (line 867) and shortening it would help, for example: "A valid anchor has a layout box, and its <dialog>, if any, is showing. A non-modal Drawer is shown as a popover and is never open."

…mments and test harness

The new comments no longer cite an issue number or an invariant id, and the focus-return harness lists the two refs it reads in its callback's dependencies.
@HelloOjasMutreja

HelloOjasMutreja commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor Author

Pushed b4bad83 for the strict-lint findings. The five new comments in useLayer.tsx (and the two older sites the lint then reported) no longer cite an issue number or an invariant id, and the focus-return harness in useLayer.test.tsx destructures the two refs and lists those in the callback's dependencies. ASTRYX_STRICT_LINT=1 eslint is clean on the four changed TypeScript files, the typecheck passes, and the Layer, Tooltip and HoverCard suites pass apart from layerViewportInset.test.ts, which also fails on main (Windows path separators). I also refreshed the description: it described the removed requestAnimationFrame poll and now describes the signals the wait uses.

On @cixzhang's earlier change request, as far as I can tell both points are addressed at this head, but I would like a re-review to confirm rather than assume:

  1. The controlled Tooltip and HoverCard in a Dialog: trigger readiness stays in useLayer's own lifecycle, with no caller-level abstraction, and the bot's review above checked both anchoring in Chromium and WebKit.
  2. The global ResizeObserver stub in internal/test-utils/src/setup.ts: this PR no longer touches that file, or any other shared test setup. The tests it changes are useLayer.test.tsx, the anchorReadiness Playwright spec and the LayerDismissal stories, none of them shared setup.

@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 strict-lint findings are fixed, and the controlled Tooltip and HoverCard in a Dialog anchor to their trigger in Chromium and WebKit, 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 addressed here. CI has not run on this head yet because it needs a maintainer's ci:run label. The inline notes on useLayer.tsx are optional comment tidy-ups.

[Automated review]

pendingAnchorElementRef.current = null;
}, []);

// A trigger with no box — 0x0, as every element not yet laid out reports —

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-blocking: isAnchorReady treats an empty 0×0 box as ready, so "0x0" here (and at line 1566) no longer describes what waits, and line 1606 says a trigger in a not-yet-modal <dialog> has no box while isAnchorReady's comment says it already has one. Something like "A trigger with no layout box, or one in a <dialog> that is not showing, is not a valid CSS anchor." covers both cases.

* Only a FINITE animation is waited on. `Animation.finished` never resolves
* for one with `iterationCount: Infinity` (it never reaches the finished play
* state), so a continuously-animating ancestor — `StatusDot`'s pulse, e.g. —
* would otherwise block a layer that anchors to it from ever opening.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-blocking: the blank line after this JSDoc detaches it from waitForAncestorAnimations, and the text can be much shorter, for example: "A transform moves the anchor without a resize. Wait for running, finite ancestor animations to end. Read the list one frame late: a just-started CSS animation is not in it yet."

Comment on lines +1757 to +1782
// A caller's OWN ref to the trigger can churn identity for reasons that
// have nothing to do with the trigger itself changing (e.g. Tooltip
// imperatively re-invoking a combined ref callback whenever one of its
// interaction handlers changes identity) — React or the caller then calls
// this ref with `null` and immediately after with the very same element.
// Removing `anchor-name` on that `null` call and re-adding it on the
// reattach is a real DOM mutation each time, and an already-open layer's
// `position-try-fallbacks` resolution does not automatically get revisited
// once the anchor comes back: the layer is left stuck at the unanchored
// fallback position for the rest of that open, even though the
// trigger never actually left the document.
//
// `addAnchorName` is already idempotent (skips the DOM write if the name
// is already present), so a `null` call defers the removal instead of
// applying it immediately: `triggerRef.current` is left pointing at the
// stale element, and a microtask is queued to actually remove its anchor
// name — but only if nothing has reattached to that exact element by
// then. A transient null-then-same-element churn cancels the pending
// removal on the reattach, so the anchor name (and the layer's anchor
// resolution) is never disturbed. A `null` followed by a DIFFERENT real
// element still removes the old element's name immediately, in the same
// synchronous call that adds the new one — no need to wait, since that is
// a genuine reassignment, not churn. A `null` followed by nothing (a real
// unmount) lets the microtask's removal go through, so a detached trigger
// does not keep carrying a dead anchor name forever (the current Popover
// contract: detaching the trigger removes its anchor association).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-blocking: three sentences keep the reason this block gives: "A combined ref can call this ref with null and then with the same element. Removing anchor-name in that gap strands an open layer at its fallback position. So a null call removes the name one microtask later, unless the same element attaches again first."

This branch was successfully deployed

1 active deployment
Preview — b4bad83c Deployed Nov 2, 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