Bug 1875424 - Make `EventStateManager` dispatch `mouseleave` events on ancestors even after the last `mouseover` target is removed r=smaug

Currently, `EventStateManager` clears the last `mouseover` element when it's
removed from the tree.  Therefore, it does not fire `mouseleave` events when
the cursor moves outside of ancestors which a `mouseenter` event is fired on.

This patch makes `EventStateManager` keep storing the parent of removing content
node which is an inclusive ancestor of the last `mouseover` target.

The following patch will make the code easier to read.

Differential Revision: https://phabricator.services.mozilla.com/D199198
This commit is contained in:
Masayuki Nakano 2024-01-26 15:05:36 +00:00
Родитель 6e0ae191ee
Коммит d244813465
9 изменённых файлов: 369 добавлений и 57 удалений

Просмотреть файл

@ -4846,14 +4846,19 @@ void EventStateManager::NotifyMouseOut(WidgetMouseEvent* aMouseEvent,
aMovingInto, aMouseEvent,
isPointer ? ePointerLeave : eMouseLeave);
// Fire mouseout
nsCOMPtr<nsIContent> lastOverElement = wrapper->mLastOverElement;
DispatchMouseOrPointerEvent(aMouseEvent, isPointer ? ePointerOut : eMouseOut,
lastOverElement, aMovingInto);
// Fire mouseout/pointerout only if the last mouseover/pointerover element has
// not been removed yet.
if (!wrapper->mLastOverElementRemoved) {
nsCOMPtr<nsIContent> lastOverElement = wrapper->mLastOverElement;
DispatchMouseOrPointerEvent(aMouseEvent,
isPointer ? ePointerOut : eMouseOut,
lastOverElement, aMovingInto);
}
leaveDispatcher.Dispatch();
wrapper->mLastOverFrame = nullptr;
wrapper->mLastOverElement = nullptr;
wrapper->mLastOverElementRemoved = false;
// Turn recursion protection back off
wrapper->mFirstOutEventElement = nullptr;
@ -4877,7 +4882,10 @@ void EventStateManager::NotifyMouseOver(WidgetMouseEvent* aMouseEvent,
RefPtr<OverOutElementsWrapper> wrapper = GetWrapperByEventID(aMouseEvent);
if (!wrapper || wrapper->mLastOverElement == aContent) return;
if (!wrapper || (!wrapper->mLastOverElementRemoved &&
wrapper->mLastOverElement == aContent)) {
return;
}
// Before firing mouseover, check for recursion
if (aContent == wrapper->mFirstOverEventElement) return;
@ -4897,7 +4905,10 @@ void EventStateManager::NotifyMouseOver(WidgetMouseEvent* aMouseEvent,
}
// Firing the DOM event in the parent document could cause all kinds
// of havoc. Reverify and take care.
if (wrapper->mLastOverElement == aContent) return;
if (!wrapper->mLastOverElementRemoved &&
wrapper->mLastOverElement == aContent) {
return;
}
// Remember mLastOverElement as the related content for the
// DispatchMouseOrPointerEvent() call below, since NotifyMouseOut() resets it,
@ -4919,13 +4930,14 @@ void EventStateManager::NotifyMouseOver(WidgetMouseEvent* aMouseEvent,
// Store the first mouseOver event we fire and don't refire mouseOver
// to that element while the first mouseOver is still ongoing.
wrapper->mFirstOverEventElement = aContent;
wrapper->mLastOverElement = aContent;
wrapper->mLastOverElementRemoved = false;
// Fire mouseover
wrapper->mLastOverFrame = DispatchMouseOrPointerEvent(
aMouseEvent, isPointer ? ePointerOver : eMouseOver, aContent,
lastOverElement);
enterDispatcher.Dispatch();
wrapper->mLastOverElement = aContent;
// Turn recursion protection back off
wrapper->mFirstOverEventElement = nullptr;
@ -5970,16 +5982,6 @@ bool EventStateManager::SetContentState(nsIContent* aContent,
return true;
}
void EventStateManager::ResetLastOverForContent(
const uint32_t& aIdx, const RefPtr<OverOutElementsWrapper>& aElemWrapper,
nsIContent* aContent) {
if (aElemWrapper && aElemWrapper->mLastOverElement &&
nsContentUtils::ContentIsFlattenedTreeDescendantOf(
aElemWrapper->mLastOverElement, aContent)) {
aElemWrapper->mLastOverElement = nullptr;
}
}
void EventStateManager::RemoveNodeFromChainIfNeeded(ElementState aState,
nsIContent* aContentRemoved,
bool aNotify) {
@ -6088,10 +6090,13 @@ void EventStateManager::ContentRemoved(Document* aDocument,
PointerEventHandler::ReleaseIfCaptureByDescendant(aContent);
// See bug 292146 for why we want to null this out
ResetLastOverForContent(0, mMouseEnterLeaveHelper, aContent);
if (mMouseEnterLeaveHelper) {
mMouseEnterLeaveHelper->ContentRemoved(*aContent);
}
for (const auto& entry : mPointersEnterLeaveHelper) {
ResetLastOverForContent(entry.GetKey(), entry.GetData(), aContent);
if (entry.GetData()) {
entry.GetData()->ContentRemoved(*aContent);
}
}
}
@ -6875,4 +6880,33 @@ bool EventStateManager::WheelPrefs::IsOverOnePageScrollAllowedY(
MIN_MULTIPLIER_VALUE_ALLOWING_OVER_ONE_PAGE_SCROLL;
}
void OverOutElementsWrapper::ContentRemoved(nsIContent& aContent) {
if (!mLastOverElement) {
return;
}
if (!nsContentUtils::ContentIsFlattenedTreeDescendantOf(mLastOverElement,
&aContent)) {
return;
}
if (mFirstOverEventElement &&
(mLastOverElement == mFirstOverEventElement ||
nsContentUtils::ContentIsFlattenedTreeDescendantOf(
mFirstOverEventElement, &aContent))) {
if (mFirstOverEventElement == mFirstOutEventElement) {
mFirstOutEventElement = nullptr;
}
mFirstOverEventElement = nullptr;
}
if (mFirstOutEventElement &&
(mLastOverElement == mFirstOutEventElement ||
nsContentUtils::ContentIsFlattenedTreeDescendantOf(mFirstOutEventElement,
&aContent))) {
mFirstOutEventElement = nullptr;
}
mLastOverElement = aContent.GetFlattenedTreeParent();
mLastOverElementRemoved = true;
}
} // namespace mozilla

Просмотреть файл

@ -64,6 +64,8 @@ class OverOutElementsWrapper final : public nsISupports {
NS_DECL_CYCLE_COLLECTING_ISUPPORTS
NS_DECL_CYCLE_COLLECTION_CLASS(OverOutElementsWrapper)
void ContentRemoved(nsIContent& aContent);
WeakFrame mLastOverFrame;
nsCOMPtr<nsIContent> mLastOverElement;
@ -75,6 +77,11 @@ class OverOutElementsWrapper final : public nsISupports {
// The last element on which we fired a out event, or null if
// the last out event we fired has finished processing.
nsCOMPtr<nsIContent> mFirstOutEventElement;
// Once the first mouseover element is removed from the tree, this is set
// to true. Then, mLastOverElement may be an ancestor of the mouseover
// element which should be the deepest target of next mouseleave element.
bool mLastOverElementRemoved = false;
};
class EventStateManager : public nsSupportsWeakReference, public nsIObserver {
@ -1127,9 +1134,6 @@ class EventStateManager : public nsSupportsWeakReference, public nsIObserver {
static void UpdateAncestorState(nsIContent* aStartNode,
nsIContent* aStopBefore, ElementState aState,
bool aAddState);
static void ResetLastOverForContent(
const uint32_t& aIdx, const RefPtr<OverOutElementsWrapper>& aChunk,
nsIContent* aClosure);
/**
* Update the attribute mLastRefPoint of the mouse event. It should be

Просмотреть файл

@ -1,23 +1,11 @@
[pointerevent_after_target_appended.html?mouse]
prefs: [layout.reflow.synthMouseMove:true]
[pointer events from mouse received before/after child attached at pointerdown]
expected: FAIL
[pointer events from mouse received before/after child moved at pointerdown]
expected: FAIL
[pointer events from mouse received before/after child moved at pointerup]
expected: FAIL
[mouse events from mouse received before/after child attached at mousedown]
expected:
if os == "android": FAIL
[mouse events from mouse received before/after child moved at mousedown]
expected: FAIL
[mouse events from mouse received before/after child moved at mouseup]
expected: FAIL
[pointerevent_after_target_appended.html?pen]
[pointer events from pen received before/after child attached at pointerdown]

Просмотреть файл

@ -27,18 +27,10 @@
[pointerevent_after_target_removed.html?mouse]
prefs: [layout.reflow.synthMouseMove:true]
[pointer events from mouse received before/after child removal at pointerdown]
expected: FAIL
[pointer events from mouse received before/after child removal at pointerup]
expected: FAIL
[mouse events from mouse received before/after child removal at mousedown]
expected: FAIL
[mouse events from mouse received before/after child removal at mouseup]
expected: FAIL
[pointerevent_after_target_removed.html?touch]
expected: TIMEOUT

Просмотреть файл

@ -0,0 +1,2 @@
[pointerevent_pointer_boundary_events_after_removing_last_over_element.html]
prefs: [layout.reflow.synthMouseMove:true]

Просмотреть файл

@ -0,0 +1,2 @@
[mouse_boundary_events_after_removing_last_over_element.html]
prefs: [layout.reflow.synthMouseMove:true]

Просмотреть файл

@ -4,15 +4,9 @@
[Removing an element at mousedown: mouseout and mouseleave should've been fired on the removed child]
expected: FAIL
[Removing an element at mousedown: mouseenter should not have been fired on the parent]
expected: FAIL
[Removing an element at mouseup: mouseout and mouseleave should've been fired on the removed child]
expected: FAIL
[Removing an element at mouseup: mouseenter should not have been fired on the parent]
expected: FAIL
[synthetic-mouse-enter-leave-over-out-button-state-after-target-removed.tentative.html?buttonType=LEFT&button=0&buttons=1]
expected:
@ -21,11 +15,5 @@
[Removing an element at mousedown: mouseout and mouseleave should've been fired on the removed child]
expected: FAIL
[Removing an element at mousedown: mouseenter should not have been fired on the parent]
expected: FAIL
[Removing an element at mouseup: mouseout and mouseleave should've been fired on the removed child]
expected: FAIL
[Removing an element at mouseup: mouseenter should not have been fired on the parent]
expected: FAIL

Просмотреть файл

@ -0,0 +1,149 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Redundant "pointerenter" shouldn't be fired without "pointerleave"s</title>
<script src=/resources/testharness.js></script>
<script src=/resources/testharnessreport.js></script>
<script src=/resources/testdriver.js></script>
<script src=/resources/testdriver-actions.js></script>
<script src=/resources/testdriver-vendor.js></script>
<script>
"use strict";
function stringifyEvents(eventArray) {
if (!eventArray.length) {
return "[]";
}
let result = "";
eventArray.forEach(event => {
if (result != "") {
result += ", ";
}
result += `${event.type}@${
event.target?.nodeType == Node.ELEMENT_NODE
? `${event.target.localName}${
event.target.id ? `#${event.target.id}` : ""
}`
: event.target?.localName
}`;
});
return result;
}
function eventsAfterClick(eventArray) {
const indexAtClick = eventArray.findIndex(e => e.type == "click");
if (indexAtClick >= 0) {
return eventArray.slice(indexAtClick + 1);
}
return [];
}
addEventListener("load", () => {
promise_test(async () => {
const div1 = document.createElement("div");
div1.setAttribute("id", "grandparent");
div1.setAttribute("style", "width: 32px; height: 32px");
const div2 = document.createElement("div");
div2.setAttribute("id", "parent");
div2.setAttribute("style", "width: 32px; height: 32px");
const div3 = document.createElement("div");
div3.setAttribute("id", "child");
div3.setAttribute("style", "width: 32px; height: 32px");
div1.appendChild(div2);
div2.appendChild(div3);
document.body.appendChild(div1);
const bodyRect = document.body.getBoundingClientRect();
const div3Rect = div3.getBoundingClientRect();
let events = [];
for (const type of ["pointerenter", "pointerleave", "pointerover", "pointerout", "pointermove"]) {
for (const node of [document.body, div1, div2, div3]) {
node.addEventListener(type, event => {
if (event.target == node) {
events.push({type: event.type, target: event.target});
}
}, {capture: true});
}
}
div3.addEventListener("click", event => {
div3.remove();
events.push({type: event.type, target: event.target});
}, {once: true});
await new test_driver.Actions()
.pointerMove(div3Rect.x + 10, div3Rect.y + 10, {})
.pointerDown()
.pointerUp() // The clicked in the child, then it's removed from the DOM tree
.pointerMove(bodyRect.x + 10, bodyRect.y + 10, {}) // Then, move onto the <body>
.send();
// FYI: Comparing `pointerenter`s before `click` requires additional
// initialization, but it's out of scope of this bug. Therefore, we
// compare only events after `click`.
const expectedEvents = [ // no events should be fired on the child due to disconnected
{ type: "pointerleave", target: div2}, // no pointerout because of first pointer move after the mutation
{ type: "pointerleave", target: div1},
{ type: "pointerover", target: document.body},
{ type: "pointermove", target: document.body},
];
assert_equals(
stringifyEvents(eventsAfterClick(events)),
stringifyEvents(expectedEvents),
);
div1.remove();
}, "After removing the last over element, redundant pointerenter events should not be fired on the ancestors");
promise_test(async () => {
const hostContainer = document.createElement("div");
hostContainer.setAttribute("id", "containerOfShadowHost");
hostContainer.setAttribute("style", "margin-top: 32px; height: 32px");
const host = document.createElement("div");
host.setAttribute("id", "shadowHost");
host.setAttribute("style", "width: 32px; height: 32px");
const root = host.attachShadow({mode: "open"});
const rootElementInShadow = document.createElement("div");
root.appendChild(rootElementInShadow);
rootElementInShadow.setAttribute("id", "divInShadow");
rootElementInShadow.setAttribute("style", "width: 32px; height: 32px");
hostContainer.appendChild(host);
document.body.appendChild(hostContainer);
const bodyRect = document.body.getBoundingClientRect();
const rootElementInShadowRect = rootElementInShadow.getBoundingClientRect();
let events = [];
for (const type of ["pointerenter", "pointerleave", "pointerover", "pointerout", "pointermove"]) {
for (const node of [document.body, hostContainer, host, root, rootElementInShadow]) {
node.addEventListener(type, event => {
if (event.target == node) {
events.push({type: event.type, target: event.target});
}
}, {capture: true});
}
}
rootElementInShadow.addEventListener("click", event => {
rootElementInShadow.remove();
events.push({type: event.type, target: event.target});
}, {once: true});
await new test_driver.Actions()
.pointerMove(rootElementInShadowRect.x + 10, rootElementInShadowRect.y + 10, {})
.pointerDown()
.pointerUp() // The clicked root element in the shadow is removed here.
.pointerMove(bodyRect.x + 10, bodyRect.y + 10, {}) // Then, move onto the <body>
.send();
// FYI: Comparing `pointerenter`s before `click` requires additional
// initialization, but it's out of scope of this bug. Therefore, we
// compare only events after `click`.
const expectedEvents = [ // no events should be fired on rootElementInShadow due to disconnected
{ type: "pointerleave", target: host}, // no pointerout because of first pointer move after the mutation
{ type: "pointerleave", target: hostContainer},
{ type: "pointerover", target: document.body},
{ type: "pointermove", target: document.body},
];
assert_equals(
stringifyEvents(eventsAfterClick(events)),
stringifyEvents(expectedEvents),
);
hostContainer.remove();
}, "After removing the root element in the shadow under the cursor, pointerleave events should be targeted outside the shadow, but redundant pointerenter events should not be fired");
}, {once: true});
</script>
</head>
<body style="padding-top: 32px"></body>
</html>

Просмотреть файл

@ -0,0 +1,153 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Redundant "mouseenter" shouldn't be fired without "mouseleave"s</title>
<script src=/resources/testharness.js></script>
<script src=/resources/testharnessreport.js></script>
<script src=/resources/testdriver.js></script>
<script src=/resources/testdriver-actions.js></script>
<script src=/resources/testdriver-vendor.js></script>
<script>
"use strict";
function stringifyEvents(eventArray) {
if (!eventArray.length) {
return "[]";
}
let result = "";
eventArray.forEach(event => {
if (result != "") {
result += ", ";
}
result += `${event.type}@${
event.target?.nodeType == Node.ELEMENT_NODE
? `${event.target.localName}${
event.target.id ? `#${event.target.id}` : ""
}`
: event.target?.localName
}`;
});
return result;
}
function eventsAfterClick(eventArray) {
const indexAtClick = eventArray.findIndex(e => e.type == "click");
if (indexAtClick >= 0) {
return eventArray.slice(indexAtClick + 1);
}
return [];
}
addEventListener("load", () => {
promise_test(async () => {
const div1 = document.createElement("div");
div1.setAttribute("id", "grandparent");
div1.setAttribute("style", "width: 32px; height: 32px");
const div2 = document.createElement("div");
div2.setAttribute("id", "parent");
div2.setAttribute("style", "width: 32px; height: 32px");
const div3 = document.createElement("div");
div3.setAttribute("id", "child");
div3.setAttribute("style", "width: 32px; height: 32px");
div1.appendChild(div2);
div2.appendChild(div3);
document.body.appendChild(div1);
const bodyRect = document.body.getBoundingClientRect();
const div3Rect = div3.getBoundingClientRect();
let events = [];
for (const type of ["mouseenter", "mouseleave", "mouseover", "mouseout", "mousemove"]) {
for (const node of [document.body, div1, div2, div3]) {
node.addEventListener(type, event => {
if (event.target == node) {
events.push({type: event.type, target: event.target});
}
}, {capture: true});
}
}
div3.addEventListener("click", event => {
div3.remove();
events.push({type: event.type, target: event.target});
}, {once: true});
await new test_driver.Actions()
.pointerMove(div3Rect.x + 10, div3Rect.y + 10, {})
.pointerDown()
.pointerUp() // The clicked in the child, then it's removed from the DOM tree
.pointerMove(bodyRect.x + 10, bodyRect.y + 10, {}) // Then, move onto the <body>
.send();
// FYI: Comparing `mouseenter`s before `click` requires additional
// initialization, but it's out of scope of this bug. Therefore, we
// compare only events after `click`.
const expectedEvents = [ // no events should be fired on the child due to disconnected
{ type: "mouseover", target: div2 }, // mouseover should be fired because of the mutation
{ type: "mouseout", target: div2}, // mouseout should be fired because of the mutation
{ type: "mouseleave", target: div2},
{ type: "mouseleave", target: div1},
{ type: "mouseover", target: document.body},
{ type: "mousemove", target: document.body},
];
assert_equals(
stringifyEvents(eventsAfterClick(events)),
stringifyEvents(expectedEvents),
);
div1.remove();
}, "After removing the last over element, redundant mouseenter events should not be fired on the ancestors");
promise_test(async () => {
const hostContainer = document.createElement("div");
hostContainer.setAttribute("id", "containerOfShadowHost");
hostContainer.setAttribute("style", "margin-top: 32px; height: 32px");
const host = document.createElement("div");
host.setAttribute("id", "shadowHost");
host.setAttribute("style", "width: 32px; height: 32px");
const root = host.attachShadow({mode: "open"});
const rootElementInShadow = document.createElement("div");
root.appendChild(rootElementInShadow);
rootElementInShadow.setAttribute("id", "divInShadow");
rootElementInShadow.setAttribute("style", "width: 32px; height: 32px");
hostContainer.appendChild(host);
document.body.appendChild(hostContainer);
const bodyRect = document.body.getBoundingClientRect();
const rootElementInShadowRect = rootElementInShadow.getBoundingClientRect();
let events = [];
for (const type of ["mouseenter", "mouseleave", "mouseover", "mouseout", "mousemove"]) {
for (const node of [document.body, hostContainer, host, root, rootElementInShadow]) {
node.addEventListener(type, event => {
if (event.target == node) {
events.push({type: event.type, target: event.target});
}
}, {capture: true});
}
}
rootElementInShadow.addEventListener("click", event => {
rootElementInShadow.remove();
events.push({type: event.type, target: event.target});
}, {once: true});
await new test_driver.Actions()
.pointerMove(rootElementInShadowRect.x + 10, rootElementInShadowRect.y + 10, {})
.pointerDown()
.pointerUp() // The clicked root element in the shadow is removed here.
.pointerMove(bodyRect.x + 10, bodyRect.y + 10, {}) // Then, move onto the <body>
.send();
// FYI: Comparing `mouseenter`s before `click` requires additional
// initialization, but it's out of scope of this bug. Therefore, we
// compare only events after `click`.
const expectedEvents = [ // no events should be fired on rootElementInShadow due to disconnected
{ type: "mouseover", target: host}, // mouseover should be fired because of the mutation
{ type: "mouseout", target: host}, // mouseout should be fired because of the mutation
{ type: "mouseleave", target: host},
{ type: "mouseleave", target: hostContainer},
{ type: "mouseover", target: document.body},
{ type: "mousemove", target: document.body},
];
assert_equals(
stringifyEvents(eventsAfterClick(events)),
stringifyEvents(expectedEvents),
);
hostContainer.remove();
}, "After removing the root element in the shadow under the cursor, mouseleave events should be targeted outside the shadow, but redundant mouseenter events should not be fired");
}, {once: true});
</script>
</head>
<body style="padding-top: 32px"></body>
</html>