A top layer element matches :hover, its ancestors do not —
but pointerenter/pointerleave still fire on those ancestors

Two specifications describe the same pointer position over the same unchanged DOM tree, and give opposite answers to "is the pointer inside #outer?".

The left table below is computed by this page directly from the two specification rules, so it is what the specs require no matter which browser you are reading this in. The right table is what this browser actually reports. They are separate on purpose: the argument here is between two specs, and any disagreement between the tables is a separate, additional matter.

Live demo

One box, always at the same place on screen and always a DOM child of #mid. The button changes one thing: whether it is in the top layer.

Hit-test target: none

Legend

Required by the specifications

Look for rows where :hover says no while the other two say yes. Those are the ancestors above the top layer element.

Reported by this browser

Should be identical to the table on the left.

Event log — pointerenter/pointerleave do not bubble, so each line is a separate dispatch to that node.



The two rules, as code

The whole inconsistency fits in two nearly identical loops. One has a break; the other does not. These are the functions that produce the left-hand table above.



The two rules, as written

Being in the top layer does not change the tree. It only changes where boxes are generated:

Documents have a top layer, an ordered set containing elements from the document. Elements in the top layer do not lay out normally based on their position in the document; instead they generate boxes as if they were siblings of the root element. CSS Positioned Layout 4 § Top Layer — drafts.csswg.org/css-position-4/#document-top-layer

Rule A — CSS truncates at the top layer.

Some user action pseudo-classes, in addition to matching on the particular element with the property in question, match on that element's ancestors as well: :hover, :active, :focus-within. Specifically, if these match on a given element, they also match on the element's flat tree ancestors, up to and including the first top layer element or the root element, whichever is encountered first. Selectors 4 § User Action Pseudo-classes — drafts.csswg.org/selectors-4/#useraction-pseudos

Note that Selectors 4 § :hover itself states the descendant rule flatly — "an element also matches :hover if one of its descendants in the flat tree […] matches the above conditions" — with no top layer clause. The truncation lives only in the parent section.

Rule B — Pointer Events does not truncate. The boundary events are defined over the plain DOM ancestor chain, and no clause anywhere mentions the top layer:

The user agent MUST fire a pointer event named pointerenter when […] the pointer has moved into an element or one of its descendants. […] and pointerleave when […] the pointer has moved out of an element and all of its descendants. Pointer Events § 5.3.2 / § 5.3.9 — #the-pointerenter-event, #the-pointerleave-event
2. Let target be hit test with viewport-relative coordinates from native
3. Let targetDomPath be inclusive ancestors of target
[…] Let enterElements be a copy of targetDomPath with all elements common to last mouse DOM path removed. For each element in enterElements […] maybe send pointerenter event Pointer Events § 4.2.17 handle native mouse move — #handle-native-mouse-move. Inclusive ancestors is DOM's plain node-tree concept. mouseenter/mouseleave are the very same steps, so they behave identically. The section carries a "needs to be revised" warning and w3c/pointerevents#285, but it is the only normative dispatch text there is.

The inconsistency. With the box in the top layer, hovering it means:

Both rules start from the same hit-test target, and neither spec references the other's answer about the ancestors of that target.

Source of the demo

These exact strings are what the page injects and runs. The button does one thing to #pop: setAttribute("popover","manual"); showPopover() to put it in the top layer, and hidePopover(); removeAttribute("popover") to take it out again.





Two more places the same split shows up

The practical failure mode: a menu kept open by .menu:hover in CSS and by a pointerenter/pointerleave hover-intent handler in JS. Move the submenu into a popover and the CSS half silently stops applying to the ancestor while the JS half keeps working. The same applies to <dialog>.showModal() and requestFullscreen(), which also use the top layer.