Skip to content

Commit eab2c2c

Browse files
Fix click dispatch for ephemeral hit-testable pseudo-elements
The `PseudoElementsHitTestable` feature allows pseudo-elements to be returned by hit-testing. However, ephemeral pseudo-elements (like `:active::after`) are often destroyed synchronously during event dispatch, causing `click` events to be silently dropped. Root Cause Steps: 1. `mousedown` sets an element to `:active`, causing `::after` to appear and become hit-testable. 2. `mouseup` hit-tests the newly created `::after` pseudo-element. 3. `PointerEventManager` caches `::after` as the `mouse_target` and dispatches the `pointerup` and `mouseup` events. 4. JavaScript listeners (e.g., React) trigger a state update and synchronous layout on `mouseup`. 5. The `:active` state is removed, causing `::after` to be disposed and orphaned (parent set to `nullptr`). 6. `PointerEventManager` attempts to dispatch the `click` event using the cached, now-orphaned `::after` target. 7. `CommonAncestor` between the `mousedown` target and the orphaned `mouseup` target returns `nullptr`, dropping the click event. This CL fixes interaction leaks by gracefully falling back to the originating element when a hit-testable pseudo-element is removed, matching pre-feature behavior: - `PseudoElement::Dispose`: Now notifies the Document via `NodeWillBeRemoved` before clearing its parent. - `MouseEventManager`: Falls back `mousedown_element_` to the originating element instead of clearing it to `nullptr`. - `PointerEventManager`: Updates pointer capture targets to the originating element. Also falls back the local `mouse_target` during click dispatch if it disconnected mid-dispatch. - `PointerEventFactory`: Updates `pointer_down_target` and `pointer_up_target` for Popover Light Dismiss calculations. - `TouchEventManager`: Falls back active touch targets to the originating element. Fixed: 500640685 Change-Id: I5d08dc3e4d50f5a8a8fb6e1a3d1f69c7127a7939 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7735452 Reviewed-by: Mason Freed <masonf@chromium.org> Commit-Queue: Daniil Sakhapov <sakhapov@chromium.org> Cr-Commit-Position: refs/heads/main@{#1616450}
1 parent 3c0a160 commit eab2c2c

2 files changed

Lines changed: 127 additions & 0 deletions

File tree

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<!DOCTYPE html>
2+
<meta charset="utf-8">
3+
<title>CSS Pseudo-elements: hit-testing a:active::after with layout on mouseup</title>
4+
<link rel="help" href="https://drafts.csswg.org/css-pseudo-4/#selectordef-after">
5+
<link rel="help" href="https://w3c.github.io/uievents/#event-type-mouseup">
6+
<script src="/resources/testharness.js"></script>
7+
<script src="/resources/testharnessreport.js"></script>
8+
<script src="/resources/testdriver.js"></script>
9+
<script src="/resources/testdriver-actions.js"></script>
10+
<script src="/resources/testdriver-vendor.js"></script>
11+
<style>
12+
#target {
13+
width: 100px;
14+
height: 100px;
15+
background: blue;
16+
position: relative;
17+
display: block;
18+
user-select: none;
19+
}
20+
#target:active::after {
21+
content: " ";
22+
position: absolute;
23+
top: 0;
24+
left: 0;
25+
width: 100px;
26+
height: 100px;
27+
background: red;
28+
}
29+
</style>
30+
<a id="target" href="javascript:void(0)"></a>
31+
<script>
32+
promise_test(async t => {
33+
const target = document.getElementById('target');
34+
let clicked = 0;
35+
target.addEventListener('click', (e) => {
36+
clicked++;
37+
assert_equals(e.target, target, "Event target should be the anchor element");
38+
assert_equals(e.pseudoTarget, null, "pseudoTarget should be null since the pseudo-element was removed before click dispatch");
39+
});
40+
41+
let mouseupTarget = null;
42+
let mouseupPseudoTarget = null;
43+
target.addEventListener('mouseup', (e) => {
44+
mouseupTarget = e.target;
45+
mouseupPseudoTarget = e.pseudoTarget;
46+
});
47+
48+
target.addEventListener('pointerup', () => {
49+
target.getBoundingClientRect();
50+
});
51+
52+
for (let i = 0; i < 5; i++) {
53+
await new test_driver.Actions()
54+
.pointerMove(0, 0, { origin: target })
55+
.pointerDown()
56+
.pointerUp()
57+
.send();
58+
}
59+
60+
assert_equals(clicked, 5, "Target should be clicked 5 times");
61+
assert_equals(mouseupTarget, target, "mouseup target should be the element");
62+
assert_equals(mouseupPseudoTarget, null, "mouseup pseudoTarget should be null since the layout triggered on pointerup destroyed it before mouseup");
63+
}, "Multiple clicks should all trigger");
64+
</script>
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<link rel="help" href="https://w3c.github.io/uievents/#event-type-mouseup">
5+
<link rel="help" href="https://w3c.github.io/uievents/#event-type-click">
6+
<script src="/resources/testharness.js"></script>
7+
<script src="/resources/testharnessreport.js"></script>
8+
<script src="/resources/testdriver.js"></script>
9+
<script src="/resources/testdriver-actions.js"></script>
10+
<script src="/resources/testdriver-vendor.js"></script>
11+
<style>
12+
#target {
13+
display: block;
14+
width: 100px;
15+
height: 100px;
16+
background: red;
17+
}
18+
#target::after {
19+
content: "AFTER";
20+
display: block;
21+
width: 100px;
22+
height: 100px;
23+
background: green;
24+
}
25+
#target.hide::after {
26+
content: none;
27+
}
28+
</style>
29+
</head>
30+
<body>
31+
<div id="target"></div>
32+
<script>
33+
promise_test(async () => {
34+
let clickCount = 0;
35+
const target = document.getElementById('target');
36+
let mousedownTarget = null;
37+
let mousedownPseudoTarget = null;
38+
39+
target.addEventListener('click', (e) => {
40+
clickCount++;
41+
assert_equals(e.target, target, "Event target should be the element");
42+
assert_equals(e.pseudoTarget, null, "pseudoTarget should be null since the pseudo-element was removed before click dispatch");
43+
});
44+
45+
target.addEventListener('mousedown', (e) => {
46+
mousedownTarget = e.target;
47+
mousedownPseudoTarget = e.pseudoTarget;
48+
target.classList.add('hide');
49+
});
50+
51+
await new test_driver.Actions()
52+
.pointerMove(0, 0, {origin: target})
53+
.pointerDown()
54+
.pointerUp()
55+
.send();
56+
57+
assert_equals(clickCount, 1, "Click event should fire even if pseudo-element disappears on mousedown");
58+
assert_equals(mousedownTarget, target, "mousedown target should be the element");
59+
assert_equals(mousedownPseudoTarget, target.pseudo("::after"), "mousedown pseudoTarget should be the ::after pseudo-element");
60+
}, "Click on pseudo-element that disappears on mousedown");
61+
</script>
62+
</body>
63+
</html>

0 commit comments

Comments
 (0)