Skip to content

feat(browser): introduce experimental pointer API - #10780

Merged
sheremet-va merged 8 commits into
mainfrom
feat/pointer-api
Oct 19, 2026
Merged

sheremet-va merged 8 commits into
mainfrom
feat/pointer-api

Conversation

@macarie

@macarie macarie commented Jul 15, 2026 •

Copy link
Copy Markdown
Member

Description

Implemented a subset of Testing Library's pointer API. Some parts behave differently due to constraints on the providers' side.

Resolves #8190
Resolves #8099
Resolves #7836
Resolves #7184

Resolves everything

Closes #6026

Not entirely sure about #7836 as this might be a bit too low-level.

Please don't delete this checklist! Before submitting the PR, please make sure you do the following:

  • It's really useful if your PR references an issue where it is discussed ahead of time. If the feature is substantial or introduces breaking changes without a discussion, PR might be closed.
  • Ideally, include a test that fails without this PR but passes with it.
  • Please, don't make changes to pnpm-lock.yaml unless you introduce a new test example.
  • Please check Allow edits by maintainers to make review process faster. Note that this option is not available for repositories that are owned by Github organizations.

Tests

  • Run the tests with pnpm test:ci.

Documentation

  • If you introduce new functionality, document it. You can run documentation with pnpm run docs command.

Changesets

  • Changes in changelog are generated from PR name. Please, make sure that it explains your changes in an understandable manner. Please, prefix changeset messages with feat:, fix:, perf:, docs:, or chore:.

@macarie
macarie force-pushed the feat/pointer-api branch from d442819 to 0f796cd Compare July 16, 2026 17:20
@netlify

netlify Bot commented Jul 16, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for vitest-dev ready!

Name Link
🔨 Latest commit 90c468b
🔍 Latest deploy log https://app.netlify.com/projects/vitest-dev/deploys/6ac4cae86a61ec0008d4bb9f
😎 Deploy Preview https://deploy-preview-10780--vitest-dev.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@userquin

Copy link
Copy Markdown
Member

I did some work here #6026 if you want to take a look, map the modifiers will be a pain iirc 🤔

@macarie

macarie commented Jul 17, 2026

Copy link
Copy Markdown
Member Author

I did some work here #6026 if you want to take a look, map the modifiers will be a pain iirc 🤔

Yes, following the same API as Testing Library makes it difficult. That's why I changed it a bit (not having to use MouseLeft to click) as I don't think compatibility is a strict requirement for this.

I might use some of the tests in your PR 😄

@macarie
macarie force-pushed the feat/pointer-api branch 4 times, most recently from 5e9cdcf to 8bbb4c8 Compare July 21, 2026 14:04
@macarie

macarie commented Jul 21, 2026

Copy link
Copy Markdown
Member Author

Todo: check if Playwright allows resizing a textarea/resizable element.

@sheremet-va sheremet-va left a comment

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.

The implementation looks good to me. Answered TODOs, I think we can go with the documentation now

@macarie

macarie commented Sep 1, 2026 •

Copy link
Copy Markdown
Member Author

Moved to Testing Library's API, there's a bit of gluing needed to have it work with Playwright, so there's no 100% API compatibility. I'll have to document what's supported and what behaves differently.

Not sure if this can still make it in time for v5, but I'd like to release it as an experimental feature anyway, so there's no rush on my end.

Still need to solve some todos and check UE's tests to see if any are worth porting.

Comment on lines +313 to +317
async pointer(options) {
// @todo
const _ = options
// await userEvent.pointer()
},

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

There's an issue with locators here which I'm not sure how to solve... We can't resolve them before-hand because interactions might insert them in the DOM, maybe a getter on target could work 🤔

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Implemented target as a getter when it's a Locator.

@macarie macarie left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I don't think user-event has any tests worth adding, most of them seem to check that it follows the "spec", which we don't need to.

// this file should not import anything directly, only types and utils

interface PointerState extends Pick<UserEventPointerInputNormalized[number], 'coords' | 'target'> {
unreleased?: string[]

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Was thinking that this might get merged into keyboard, not sure tho as it would allow doing weird stuff like

userEvent.keyboard(target,  '[Shift>]')
userEvent.pointer([{ target, keys: '[MouseLeft][/Shift]' }])

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Merged the unreleased part of pointer into the keyboard management.

@macarie macarie changed the title feat(browser): pointer API feat(browser): introduce experimental pointer API Sep 3, 2026
Comment thread docs/api/browser/interactivity.md Outdated
This method is intended for testing UI that explicitly listens to `wheel` events (e.g., custom zoom controls, horizontal tab scrolling, canvas interactions). If you need to scroll the page to bring an element into view, rely on the built-in automatic scrolling functionality provided by other `userEvent` methods or [locator actions](/api/browser/locators#methods) instead.
:::

## userEvent.pointer <Version type="experimental">5.0.0</Version> <Experimental /> {#userevent-pointer}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

todo: change version number here and in @since JSDocs

@macarie

macarie commented Sep 14, 2026

Copy link
Copy Markdown
Member Author

While working on applying the scaling to coords for the pointer API, I found an issue with other interactivity APIs (click, its variants, and hover): when providing a target and a position, the iframe's scale was not accounted for.

Playwright created the offset using the raw position numbers from the parent page and, at small scales, this often resulted in clicking/hovering outside the element or even the iframe.

This has been fixed in this PR too.

Unless I find other bugs, I think this branch should be ready for an initial release...

Visibly exhausted "Friends" character saying he has not slept in 4 years

@hi-ogawa hi-ogawa added this to the 5.1.0 milestone Sep 16, 2026

interface Coordinates { x?: number; y?: number }

export async function resolvePageCoordinates(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

How does this relate to the client side scale position adjustment processPlaywrightPosition?

function processPlaywrightPosition(position: { x: number; y: number }) {

@macarie macarie Sep 18, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yes, looks like we already scaled position but only on the client-side, I forgot about that 🙃

The issue here is more like we can't compose actions on the server side rather than it not working in general (it works when called from the client).

I wonder if we should then keep the position-scaling behavior on client-side, server-side, or maybe both? We could provide a "scaled" boolean internal option which disables scaling if it was already computed somewhere else.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think the concern of switching fully on server-side is the extra roundtrip of evaluate, so keeping both seems fine to start with.

We could provide a "scaled" boolean internal option which disables scaling if it was already computed somewhere else.

I think it's currently doubling the ui scale for client click API since position is already compensated on client side?

position: options.position
      && await resolvePageCoordinates(context, options.position, true)

So it looks like it requires differentiating them somehow, but that means the client can pass the scale directly to server, so we don't need server-side scale evaluation?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I think it's currently doubling the ui scale for client click API since position is already compensated on client side?

Yes. In this branch, a call from the client, with a 0.5 iframe scale, resolves like this: [userEvent] 100px → [client] 50px → [server] 25px

but that means the client can pass the scale directly to server, so we don't need server-side scale evaluation?

Yes, something like this:

position: options._scaled
  ? options.position
  : options.position && resolvePageCoordinates(context, options.position, true)

This way we can compose actions on the server too, without having to scale manually each time we call them. If it's not something we want I can just call the Playwright method on the locator, works either way for me

@hi-ogawa hi-ogawa Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

but that means the client can pass the scale directly to server, so we don't need server-side scale evaluation?

Yes, something like this:

position: options._scaled
  ? options.position
  : options.position && resolvePageCoordinates(context, options.position, true)

Does this require server-side evaluate? If client can pass x/y/scale, then server pointer logic can use that to compensate before internal calling server click/hover? something like:

// client click
// -> server click (options.position already scaled on client)

// client pointer (obtain x/y/scale on client and pass as internal option)
// -> server pointer
//   -> for each server click, adjust position with x/y/scale passed from client
//     -> server click (options.position already scaled on caller side)

So, server click can assume position is always compensated

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Unfortunately, I don't think we can get rid of the server-side evaluate 😕

With pointer being an asynchronous chain of actions, there's nothing stopping the user from resizing the pane containing the iframe while it runs, so we can't safely save the scaling and iframe coordinates when calling the server

For now I might just scale the values when calling the function, we can re-evaluate in the future if we have to call these functions in other places

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

With pointer being an asynchronous chain of actions, there's nothing stopping the user from resizing the pane containing the iframe while it runs, so we can't safely save the scaling and iframe coordinates when calling the server

Personally, I wouldn't worry about this case. This sounds like also already true for existing click scaling, which can take time if playwright retries internally, but compensated position is computed once on client side before playwright-level click.

I'm okay with current shape of having resolvePageCoordinates on server though.

@sheremet-va sheremet-va Oct 5, 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.

I agree with Hiroshi here, the rescaling while the function runs looks more like intentionally breaking the intended implementation rather than a real use case (especially in headless mode!).

If we can avoid the round trip, I think we should. We already do that during "click" where you could technically also resize while it tries to find the element. The resizing argument is just too contrived in my opinion, we can come up with bad faith arguments all day - what if the user switches tabs? what if the user calls this in parallel? what if the user accesses top level context and intentionally changes the scaling!? If you are trying to break it, you will find a way to break it

Browser UI should be treated as a preview environment, not a test one. We should optimize for headless mode, in my opinion.

@macarie macarie Oct 6, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

That makes sense, yes.

I moved the scaling and offset logic to the client and computed their values while serializing/normalizing the input.

I'm not entirely sure if that's the best place to do it tho since different providers could (do?) treat raw coordinates differently, but afaik we can't override userEvent methods from the provider.

@macarie
macarie force-pushed the feat/pointer-api branch 2 times, most recently from dc3e2d5 to 3639476 Compare September 21, 2026 13:15
Comment thread test/browser/specs/pointer.test.ts Resolved
@macarie
macarie marked this pull request as ready for review September 21, 2026 15:16

@hi-ogawa hi-ogawa left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The trace view action is missing. We can add it here

const ACTION_TRACE_COMMANDS = new Set([

Not sure how to handle multi targets in one action, but maybe fine without element highlight for now.

Comment thread docs/api/browser/interactivity.md Resolved

@hi-ogawa hi-ogawa left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I've left reviews and looks good overall!

@Alanoll

Alanoll commented Sep 27, 2026 •

Copy link
Copy Markdown

I'm just curious, how does this play with browser parallelism? I'm excited to try this when it's published, but when I was manipulating playwright's mouse in v4 tests, I ran into parallelism problems with multiple tests moving the mouse, because playwright only has one mouse.

I ask because this looks like it also manipulates the page's mouse object (where the current userEvent only performs click actions). I haven't run needed to try yet, but I would assume keyboard would have a similar limitation.

My use case was actually handling mouse interactions on an interactive canvas, so moving the mouse in one test, causes it to move in another.

Disabling parallelism resolved my issues. Just curious, if this has similar limitations, then maybe a documentation note is needed to avoid future issues.

@hi-ogawa

hi-ogawa commented Sep 28, 2026 •

Copy link
Copy Markdown
Collaborator

I'm just curious, how does this play with browser parallelism? I'm excited to try this when it's published, but when I was manipulating playwright's mouse in v4 tests, I ran into parallelism problems with multiple tests moving the mouse, because playwright only has one mouse.

I think parallel mouse is expected to work. If it doesn't, it would be a playwright bug. For example, we had issue with playwright firefox like #10096. It was reproduced upstream in Playwright microsoft/playwright#40562, and after the fix landed in Playwright 1.61.0.

If you see any issues with latest vitest/playwright, please feel free to create an issue with reproduction.

@Alanoll

Alanoll commented Sep 28, 2026

Copy link
Copy Markdown

Interesting. Good to know on the expectation.

I haven't upgraded to v5 yet, and maybe I just need to upgrade the playwright package itself first to try. When I get some time, I'll try and reproduce my scenario in isolation in my current setup. I am on some older versions of various packages, so entirely possible it's a bug already fixed. I am issuing the calls via the page.mouse playwright APIs directly using Vitest custom commands. If I can reproduce, I'll open a new issue for it since it's not directly related to this PR.

@macarie
macarie force-pushed the feat/pointer-api branch 2 times, most recently from 7dc08f0 to 87b7d38 Compare September 28, 2026 14:39
@macarie

macarie commented Sep 28, 2026

Copy link
Copy Markdown
Member Author

I squashed all the previous commits to avoid having to fix too many formatting-related conflicts, hopefully I didn't add unrelated stuff while resolving them tho 🫣

Anyway, I addressed all the review comments, but this still depends on #11221 to pass.

hi-ogawa
hi-ogawa previously approved these changes Oct 5, 2026
@sheremet-va
sheremet-va merged commit 5dc09f0 into main Oct 19, 2026
@sheremet-va
sheremet-va deleted the feat/pointer-api branch October 19, 2026 08:47
@github-actions github-actions Bot locked and limited conversation to collaborators Nov 3, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

5 participants