Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/injected/src/injectedScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1113,7 +1113,7 @@ export class InjectedScript {
}[action];
let result: 'done' | { hitTargetDescription: string } | undefined;

const listener = (event: PointerEvent | MouseEvent | TouchEvent) => {
let listener: ((event: PointerEvent | MouseEvent | TouchEvent) => void) | undefined = (event: PointerEvent | MouseEvent | TouchEvent) => {
// Ignore events that we do not expect to intercept.
if (!events.has(event.type))
return;
Expand Down Expand Up @@ -1142,6 +1142,7 @@ export class InjectedScript {
const stop = () => {
if (this._hitTargetInterceptor === listener)
this._hitTargetInterceptor = undefined;
listener = undefined;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

looks good

// If we did not get any events, consider things working. Possible causes:
// - JavaScript is disabled (webkit-only).
// - Some <iframe> overlays the element from another frame.
Expand Down
2 changes: 2 additions & 0 deletions packages/playwright-core/src/server/chromium/crPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,8 @@ export class CRPage implements PageDelegate {
}

async requestGC(): Promise<void> {
// A no-op mouse move off-viewport clears Blink's native hit-target state, which retains the last-targeted element as a GC root invisible to V8.
await this._mainFrameSession._client.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: -1, y: -1 });

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I don't think we can explain this line either to ourselves or to others.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You're right that the original comment didn't hold up. I dug into it: removing the mouse move, doubling collectGarbage, and swapping in an unrelated CDP command all still leave the test failing most of the time; only dispatching a new mouseMoved event (any coordinate) reliably releases the reference. That points to Chromium's own hover/mouseout tracking holding onto the last hit-tested node until the next pointer event, which HeapProfiler.collectGarbage can't see. Rewrote the comment to describe that behavior instead of naming Blink internals I can't fully verify from here.

@pavelfeldman pavelfeldman Jul 10, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

What I meant was that it should be fixed in Chrome. We can accept clearing the field part though

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Dug into this a bit more. BoundaryEventDispatchTracksNodeRemoval is stable (shipped, public: true in runtime_enabled_features.json5) and already retargets element_under_mouse_ via MouseEventManager::NodeWillBeRemoved when a hovered node is removed. So the field the mouse-move hack is actually clearing isn't element_under_mouse_ — it's something else, likely tied to the CDP Input.dispatchMouseEvent path itself rather than the DOM-level hover tracking.

Can you point at which field you mean? If there's a known direct way to clear it I'll patch that instead of the synthetic event. If not, I'd rather file a proper Chromium bug than carry something I can't explain.

In the meantime, would you take this split? The regression test, the Firefox closure fix, and the fixme for the known Firefox gap are independent of this line and ready to land. I'll follow up with the Chromium-side fix once I know what to target.

await this._mainFrameSession._client.send('HeapProfiler.collectGarbage');
}

Expand Down
11 changes: 11 additions & 0 deletions tests/page/page-request-gc.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,14 @@ test('should work', async ({ page }) => {
await page.requestGC();
expect(await page.evaluate(() => globalThis.weakRef.deref())).toBe(undefined);
});

test('should collect element retained by locator hit-target interceptor after detach', async ({ page }) => {
await page.setContent('<button id="btn">click me</button>');
await page.locator('#btn').click();
await page.evaluate(() => {
globalThis.weakRef = new WeakRef(document.getElementById('btn')!);
document.getElementById('btn')!.remove();
});
await page.requestGC();
expect(await page.evaluate(() => globalThis.weakRef.deref())).toBe(undefined);
});