Skip to content

fix(HoverCard): a replaced trigger element is wired in place of the old one - #9593

Draft
cixzhang wants to merge 3 commits into
mainfrom
lane4/hovercard-replaced-trigger
Draft

cixzhang wants to merge 3 commits into
mainfrom
lane4/hovercard-replaced-trigger

Conversation

@cixzhang

@cixzhang cixzhang commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

User impact

A HoverCard wraps an element whose node changes after mount. For example, a list row re-renders a name as a link once it loads, or a child component swaps its root from a <button> to an <a>. The new element is then unwired:

  • Screen readers: no aria-haspopup/aria-controls (named card) or aria-describedby (unnamed card), so the popup isn't announced.
  • Pointer and keyboard users: hover and focus don't open the card, until it happens to open or close another way, or label changes.

Problem and solution fit

  • Why does it fail? The element-children wiring is a layout effect keyed on open state, label, the layer id and the hook's ref. A new first element changes none of those, so the effect never sees it. A child component that swaps its own root doesn't even re-render HoverCard.
  • Why does it harm the task? The trigger is how the card is found and announced. An unwired trigger looks like plain content.
  • Why does it matter? Swapping a node is ordinary React (keys, conditional roots, loading states). architecture:react-component-runtime INV6 makes node replacement a lifecycle event that owners must handle.

Expected behavior and authority

  • component:HoverCard FR2 (element children attach to their first element) and FR3 (trigger popup attributes). The record lists "A replaced trigger loses its wiring" under Current-state impact as a gap of FR2/FR3.
  • architecture:react-component-runtime INV6: a node owner handles replacement, and cleanup for the old node completes before the new node becomes active.

Smallest restoration

  • The display: contents wrapper's child list is watched through the shared observeMutations (utils/sharedMutationObserver), so repeated cards share one observer. The Effect names the DOM as the external system it syncs, and cleanup unsubscribes.
  • When the first element changes, the last observed element is kept in a ref and a replacement counter in state goes up. The wiring effect depends on that counter, so it re-runs, releasing the old element (attributes restored, listeners removed) before it wires the new one.
  • On mount: nothing is set, so there's no extra render, which matters for tables of cards. A child-list change that keeps the same first element is ignored.
  • Record:
    • FR2 states the replacement behavior.
    • The Current-state bullet is dropped, and the readiness count goes from eight gaps to seven.
    • The FR1/FR2/FR8 verification row names the replaced-trigger tests.

Evidence

  • Reproduction before the change: the three new tests fail on main. After a replacement, the new element has no aria-haspopup (named) or aria-describedby (unnamed), and hover doesn't open the card.
  • Result after the change: all three pass.
  • Representative unchanged path: the whole HoverCard suite passes unchanged (68 tests), including "does not rebind trigger listeners on rerender" and the attribute-restore tests.
  • Regression test or other mutation-sensitive proof: removing the counter from the wiring effect's dependencies fails the replacement tests. A reorder test checks that the old first element, still in the DOM, loses its popup attributes.

Scope

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

Testing

  • pnpm vitest run packages/core/src/HoverCard: 69 passed.
  • pnpm check:knowledge: aligned.
  • No visual change, so no browser capture is needed; jsdom runs MutationObserver natively.

Writing rubric results

Run over the FR2 row, Current-state impact, and the FR1/FR2/FR8 verification row (docs/contributing/spec-writing.md):

  • Present tense, describes the system: yes.
  • One owner per fact: HoverCard owns its trigger wiring, and INV6 stays the cited rule.
  • Testable: FR2 names the two replacement paths, and the verification row names the tests.
  • Plain language: short sentences, active voice.

…ld one

component:HoverCard FR2/FR3 and architecture:react-component-runtime INV6
(node replacement is a lifecycle event). The element-children wiring ran
only when open state, label, id or the hook's ref changed, so a trigger
replaced after mount (new children, or a child that swaps its own root
without a render here) had no popup attributes, description, or hover and
focus opening.

A MutationObserver on the display:contents wrapper (the DOM is the
external system) records the replaced first element in state, which the
wiring effect depends on: on a replacement the old element is released
(attributes restored, listeners removed) before the new one is wired. No
extra render on mount. Tests: new children, a child that swaps its own root,
and an unnamed card (red before). Record: FR2 states it, the Current-state
bullet is dropped, and the verification row names the tests.
@vercel

vercel Bot commented Nov 2, 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:54pm UTC

Request Review

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

astracat-bot Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Nov 2, 2026, 8:59 PM UTC

@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Nov 2, 2026
@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

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


Generated by PR Enrichment workflow | View current CI run

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

Requesting changes: the fix restores HoverCard's wiring for a replaced trigger as the HoverCard record's FR2 and FR3 require, and the new tests prove it, but the mechanism needs two adjustments before it can merge.

  • Subscribe through observeMutations(wrapper, {childList: true}, …) from utils/sharedMutationObserver instead of a component-owned MutationObserver, so repeated cards share one observer.
  • Track a replacement with a number, keeping the last observed element in a ref, instead of storing the Element in state (architecture:react-component-runtime INV1).
  • Change the record's "eight further implementation gaps" to seven, now that this gap is closed.

A test where the old first element stays in the DOM (children reordered) would also cover the old element's release.

[Automated review]

) {
return;
}
const observer = new MutationObserver(() => {

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 duplicates observeMutations in utils/sharedMutationObserver.ts, which shares one observer across every element watched with the same options; as written, 50 cards create 50 observers. Subscribe with return observeMutations(wrapper, {childList: true}, onChildListChange); and drop the local typeof MutationObserver guard, which the utility already has.

// mount: by new children, or by a child component that swaps its own root
// without a render here. Syncs with the DOM: on a replacement the wiring
// below re-runs, so the old element is released before the new one is wired.
const [replacedTrigger, setReplacedTrigger] = useState<Element | null>(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.

architecture:react-component-runtime INV1 keeps DOM nodes out of React state. Keep the last observed element in a ref, bump a number (setTriggerKey(key => key + 1)) only when wrapper.firstElementChild differs from it, and let the wiring effect depend on that number.

`auto` does not recognize, such as a `<summary>` or an SVG link, never opens
on focus. `architecture:interaction-modality` INV4 keeps hover from being the
only discovery path.
- **A replaced trigger loses its wiring.** The card wires its trigger element

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

With this entry removed, seven implementation gaps remain, but the Compatibility/readiness row (line 48) still says "eight further implementation gaps".

@cixzhang

cixzhang commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

/approve-spec 7f2d8a4

@github-actions github-actions Bot removed the needs:spec-owner-review Current knowledge records await owner approval label Nov 2, 2026
…r, and count replacements

CodeBunny on #9593: subscribe through utils/sharedMutationObserver's
observeMutations, so repeated cards share one observer, and track a
replacement as a number with the last observed element in a ref instead of
an Element in state (react-component-runtime INV1). A reorder test now
checks that the old first element, still in the DOM, is released; the
readiness count drops to seven gaps.
@cixzhang

cixzhang commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

/approve-spec d941bcc

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

Recommending approval: the change rewires a replaced HoverCard trigger as the HoverCard record's FR2 and FR3 require, and the new tests fail without the fix and pass with it. Two optional follow-ups: a test that focus on the swapped element opens the card, since the changeset claims it, and a shorter comment above the new observer effect (suggested inline).

[Automated review]

Comment on lines +313 to +316
// The wrapper's first element is the trigger, and it can be replaced after
// mount: by new children, or by a child component that swaps its own root
// without a render here. Syncs with the DOM: on a replacement the wiring
// below re-runs, so the old element is released before the new one is wired.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Optional (comment style): the first sentence runs 31 words over two topics in the passive. A shorter form that keeps the reason and the system note:

// New children, or a child that swaps its own root, can replace the trigger
// without a render here. Syncs with the DOM. A new first element re-runs the
// wiring below, and its cleanup releases the old element first.

This branch was successfully deployed

1 active deployment
Preview — d941bcc2 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.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant