Skip to content

feat: Add blur simulation and implicit focus tracking - #164

Merged
mcollovati merged 18 commits into
mainfrom
bugfix/blur-issues
Sep 22, 2026
Merged

mcollovati merged 18 commits into
mainfrom
bugfix/blur-issues

Conversation

@mstahv

@mstahv mstahv commented Aug 27, 2026

Copy link
Copy Markdown
Member

Summary

Browserless tests now track which component has keyboard focus, so focus and blur listeners fire on their own when a tester clicks a component or sets its value. ComponentTester also gets explicit focus(), blur() and isFocused() methods, and server-side Focusable.focus() / blur() calls are simulated too.

Fixes #163

What changed

Behavior change: interactions made through testers now fire focus and blur events. This affects all users, but only tests whose components have focus or blur listeners:

  • click() (the shared Clickable implementation) and setValueAsUser(...) first move focus to the component, which blurs the previously focused one. Blur runs before the click is handled, like in a browser.
  • Existing tests that count events, or that expect no focus/blur, may see extra events now.
  • If the application has a Focusable.focus() or blur() call pending, the simulation reads the pending JavaScript queue, which consumes it — any other JavaScript queued at the same time is dropped. When no focus or blur call is pending the queue is left untouched, so JavaScript scheduled by the application can still be asserted on. Everything consumed is listed in a debug log under com.vaadin.browserless.FocusTracker. Handling the queue centrally is tracked in #221.

New behaviour:

  • New package-private FocusTracker keeps the focused component per UI. It fires focus / blur DOM events as if they came from the client, and mirrors Flow's markers so that a server-initiated focus or blur reports isFromClient() == false.
  • Server-side Focusable.focus() / blur() calls are applied at the end of a focus-tracked interaction, on roundTrip(), and when isFocused() is called.
  • focus() and blur() fail fast: IllegalArgumentException if the component is not Focusable, IllegalStateException if it is disabled or not attached. Read-only fields can still be focused.
  • Only Focusable components take focus. Interacting with anything else blurs the previous component and leaves nothing focused, like focus falling back to the document body.
  • Focus is per UI, so it does not survive a page reload, not even in a @PreserveOnRefresh view.
  • Testers that fire their events directly (radio button, menu bar, context menu item clicks) do not take part in focus tracking. This is documented in the README together with the other limits.

Use case

A form focuses the next field from a value change listener, and a "save" button validates the amount in a blur listener. The developer wants a test that proves both happen in the right order, without any browser.

TextField amount = new TextField("Amount");
TextField note = new TextField("Note");
amount.addValueChangeListener(e -> note.focus()); // application logic
amount.addBlurListener(e -> validate(amount.getValue()));

// test
test(amount).setValue("100");      // focuses the field, then fires the value change
assertTrue(test(note).isFocused()); // server-side focus() was applied

test(save).click();                 // blurs "note" first, then handles the click
assertFalse(test(note).isFocused());

// explicit control when nothing else is interacted with afterwards
test(amount).focus();
test(amount).blur();

API Changes

com.vaadin.browserless.ComponentTester

// Added
public void focus() // moves simulated keyboard focus to the wrapped component
public void blur() // fires a blur event; no-op if the component is not focused
public boolean isFocused() // true if the component has focus, server-side focus() included

Test summary

Implicit focus on interactions:

  • Field focused when a value is set through the tester
  • Previously focused field blurred by a click or by setting another field
  • Blur listener runs before the click listener
  • No blur or re-focus on consecutive edits of the same field
  • Click on a non-focusable component blurs the field and focuses nothing

Explicit focus API:

  • Blur through the tester fires the blur event as from the client
  • Blur on a non-focused component is a no-op
  • focus() / blur() fail with a readable message when the component is not Focusable
  • Read-only field focusable, disabled or detached field fails

Server-side Focusable calls:

  • focus() from a value change listener moves focus and blurs the previous field, not from client
  • focus(FocusOption...) detected as well
  • blur() fires the blur event and clears focus, not from client
  • Focus scheduled outside an interaction applied on roundTrip()
  • Field focused in a dialog opened by a click listener

Page reload:

  • Focus cleared after a reload, also in a @PreserveOnRefresh view

mstahv and others added 3 commits August 27, 2026 12:10
Reproduces https://vaadin.com/forum/t/missing-blur-event-simulation-api-in-browserless-test/179736

Business logic is often attached to blur listeners, but there is no
public tester API to trigger blur, and Focusable.blur() is a no-op
without a browser. Beyond an explicit API, focus and blur should
happen implicitly like with a real user: interacting with a component
through a tester should focus it and blur the previously focused one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
FocusTracker keeps per-UI bookkeeping of the focused component and
fires focus/blur DOM events through ElementListenerMap, so listeners
see events as if they came from the client.

Focus moves implicitly like with a real user: setting a value through
a tester focuses the field, and interacting with any other component
(another setValue, a click, ...) blurs the previously focused one
before the new interaction is handled. Interacting again with the
already focused component is a no-op. ComponentTester additionally
exposes explicit focus() and blur() methods.

Fixes the case from
https://vaadin.com/forum/t/missing-blur-event-simulation-api-in-browserless-test/179736

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread junit6/src/test/java/com/vaadin/browserless/BlurSimulationTest.java
mstahv and others added 3 commits August 27, 2026 18:10
Focusable.focus()/blur() only schedule a client-side JavaScript call,
so they used to be no-ops in browserless tests. FocusTracker now
consumes the pending JavaScript queue like a browser would: it
materializes beforeClientResponse executions, picks up this.focus()/
this.blur() invocations and fires the corresponding focus/blur DOM
events back, updating the focus bookkeeping.

The queue is processed at the end of each simulated user interaction
(click, setValue, explicit focus/blur) and on server round-trips, so
the typical case of a click listener opening a dialog and focusing a
field in it works implicitly. ComponentTester also gets isFocused()
for asserting the currently focused component.

Addresses review feedback in
#164

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Calling focus() directly in test code was contrived; focusing the next
field from a value change listener is the realistic application logic
this simulates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread junit6/src/test/java/com/vaadin/browserless/BlurSimulationTest.java
Flow's Focusable.focus()/blur() set _nextFocusIsFromClient/
_nextBlurIsFromClient markers in the generated JavaScript, and the
FocusEvent/BlurEvent @eventdata constructors use them to override
fromClient. Mirror that: server-initiated focus/blur simulation now
includes the marker in the DOM event data, so isFromClient() returns
false, matching real browser behavior. The blur fired on the
previously focused component stays fromClient=true, as it is a plain
browser reaction.

Also adds a test proving that focus(FocusOption...), which generates
this.focus($0) instead of this.focus(), is detected as well.

Addresses review feedback from knoobie in #164.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Resolved a conflict in `ComponentTester`, where main split `setValueAsUser`
into an overload taking the target field, while this branch wrapped it in
focus tracking. Focus tracking now happens in the single argument overload,
which acts on the wrapped component.
Comment thread shared/src/main/java/com/vaadin/browserless/FocusTracker.java Outdated
// receiving the invocations would
ui.getInternals().getStateTree()
.runExecutionsBeforeClientResponse();
for (PendingJavaScriptInvocation invocation : ui.getInternals()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This loop is consuming the while-pending invocation queue but processing only focus and blur. If other testers or testing features need to inspect the invocation as well, they will silently do nothing.
I guess we need a centralized place to handle the JavaScript invocation queue, where testers can register their own handlers for specific expressions.
I wonder if this would need some changes in Flow to simplify things.
Worth creating an issue. @totally-not-ai please create one

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This loop is consuming the while-pending invocation queue but processing only focus and blur. If other testers or testing features need to inspect the invocation as well, they will silently do nothing.
I guess we need a centralized place to handle the JavaScript invocation queue, where testers can register their own handlers for specific expressions.
I wonder if this would need some changes in Flow to simplify things.
Worth creating an issue. @totally-not-ai please create one

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Created #221 for the central JavaScript invocation dispatch — including the note about possibly needing a supported way in Flow to peek at pending invocations instead of dumping them. @mcollovati

The rest of the review feedback is addressed in d295f4b:

  • FocusTracker now implements Serializable
  • focused is recorded only after the blur/focus events have been delivered
  • blur is no longer fired on a component that does not have focus
  • no more UI.getCurrent() fallback: a component not attached to a UI can neither take nor lose focus and fails with an IllegalStateException (@Artur-)
  • focus()/blur() fail fast when the component is not Focusable, and use the usability check without the read-only rule, so a read-only field can still be focused while a disabled one cannot
  • manual @since tag dropped

Five tests in BlurSimulationTest cover the new edge cases (blur without focus, non-focusable component, read-only, disabled, detached). Full build green.

Comment thread shared/src/main/java/com/vaadin/browserless/FocusTracker.java Outdated
Comment thread shared/src/main/java/com/vaadin/browserless/FocusTracker.java Outdated
Comment thread shared/src/main/java/com/vaadin/browserless/FocusTracker.java Outdated
Comment thread shared/src/main/java/com/vaadin/browserless/ComponentTester.java Outdated
Comment thread shared/src/main/java/com/vaadin/browserless/ComponentTester.java Outdated
Comment thread shared/src/main/java/com/vaadin/browserless/FocusTracker.java Outdated
- Make FocusTracker serializable, it is stored in the UI
- Record the focused component only after the blur and focus events have
  been delivered, so that a failing listener cannot leave a wrong
  component marked as focused
- Do not fire blur on a component that does not have focus, like in a
  browser
- Resolve the UI from the component instead of falling back to
  UI.getCurrent(): a component that is not attached to a UI can neither
  take nor lose focus, which now fails with an IllegalStateException
- Make ComponentTester.focus() and blur() fail fast for components that
  are not Focusable, and check usability without the read-only rule, as
  a read-only field can still be focused while a disabled one cannot
- Drop the manually written @SInCE tag

Handling the pending JavaScript invocation queue centrally, so that other
testers can react to scheduled JS as well, is tracked in #221.
- Only consume the pending JavaScript queue when a focus or blur call is
  actually pending, so that JavaScript scheduled by the application
  survives an interaction and can still be asserted on; the remaining
  limitation is documented and tracked in #221
- Add a test for server-side Focusable.blur(), which is the other half of
  the coupling to the JavaScript generated by Flow and was not covered
- Add tests for server-side focus applied on a plain round-trip and for
  interacting with a component that cannot take focus
- Make FocusTracker package-private: all callers are in the same package
  and focus state is exposed through ComponentTester.isFocused()
- Drop the isCanceled() check, as canceled invocations are already
  filtered out by dumpPendingJavaScriptInvocations()
- Drop the blur test that bypassed the tester, it only exercised Flow's
  own event dispatch and duplicated the tester test
@Artur-

Artur- commented Sep 17, 2026

Copy link
Copy Markdown
Member

What's the status of this, are there unresolved questions? We should not block this on the Flow JS mechanism - we can adopt that the day it is available

totally-not-ai Bot and others added 2 commits September 17, 2026 12:35
Describes implicit focus on tester interactions, the explicit focus(),
blur() and isFocused() methods, how server-side Focusable calls are
applied, and the known limitations, so that the behaviour and its bounds
are visible without reading the implementation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Focus is wired into the common click implementation, value changes made
through setValueAsUser and the explicit focus/blur methods. Testers that
fire their events directly, such as the radio button, menu bar and
context menu item clicks, do not participate, and a queued server-side
Focusable call is applied on the next tracked interaction, round-trip or
focus query. Also state that focus and blur fail for a disabled or
detached component instead of doing nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@totally-not-ai totally-not-ai Bot changed the title Add blur simulation and implicit focus tracking tests Add blur simulation and implicit focus tracking Sep 17, 2026
@totally-not-ai

Copy link
Copy Markdown
Contributor

@Artur- Status: no open questions from my side, and nothing here waits for Flow.

All review comments are addressed:

  • FocusTracker implements Serializable, the focused component is recorded only after the blur/focus events have been delivered, and blur is no longer fired on a component that does not have focus
  • no UI.getCurrent() fallback: a component that is not attached to a UI can neither take nor lose focus and fails with an IllegalStateException
  • focus()/blur() fail fast for components that are not Focusable and use the usability check without the read-only rule, so a read-only field can still be focused while a disabled one cannot
  • FocusTracker is package-private, so the only new public API is ComponentTester.focus(), blur() and isFocused()
  • the README now documents the simulation and its limitations

On the JavaScript mechanism: agreed, it is not a blocker. Detection matches the JS that Focusable generates today and is covered by tests for focus(), focus(FocusOption...) and blur(), so a change on the Flow side fails the build immediately instead of silently. The pending queue is now only consumed when a focus or blur call is actually pending, so JavaScript scheduled by the application survives an ordinary interaction. #221 tracks handling the queue centrally; adopting it later is an internal change behind the same tester API.

Ready for another look.

Comment on lines +340 to +341
"Parameter component: invalid value " + component
+ ": not a Focusable: " + component.getClass());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This error message looks a bit cryptic. Make it easier to read like <component class> is not Focusable

@mcollovati

Copy link
Copy Markdown
Contributor

The pending JavaScript invocation check before purge does not resolve the problem of vanishing simulations, but it is better than nothing.
Let's keep it that way until a proper handling is implemented.

totally-not-ai Bot and others added 3 commits September 17, 2026 14:36
Report it the same way as the other usability failures, as
"Div[text='Not focusable'] is not Focusable", and assert the message in
the test so that it stays readable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reading the pending JavaScript queue consumes it, so unrelated
invocations queued together with a focus or blur call disappear. Until
the queue is handled centrally, list what was dropped in a debug log so
that a vanished JavaScript simulation can be traced instead of looking
like a framework bug. Mentioned in the README as well.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Log inline instead of through a one-line helper, and cover all consumed
invocations, including the focus and blur calls themselves and calls
whose owner has no component, so that the log matches what actually
disappears from the queue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@totally-not-ai

Copy link
Copy Markdown
Contributor

@mcollovati Agreed, the pre-check only narrows the window — it is kept as is.

One thing added so the remaining gap is at least diagnosable: the expressions that are consumed together with a focus or blur call are now listed in a debug log under com.vaadin.browserless.FocusTracker, so a JavaScript simulation vanishing from the queue can be traced instead of looking like a framework bug. The README limitation note points at that log too.

Why re-queueing the untouched invocations is not an option is recorded in #221, together with the current state: dumpPendingJavaScriptInvocations() marks every invocation as sent, and PendingJavaScriptInvocation.setSentToBrowser() asserts it is called only once, so a re-added invocation fails on the next dump. A proper fix needs either a non-destructive read of the queue (UIInternals.getPendingJavaScriptInvocations() is package-private today) or a dispatcher owning the queue for the whole test.

mcollovati
mcollovati previously approved these changes Sep 17, 2026
@mcollovati

Copy link
Copy Markdown
Contributor

@totally-not-ai merge main and resolve the conflicts

totally-not-ai Bot and others added 2 commits September 17, 2026 15:20
The focused component is tracked per UI and a reload builds a new one,
so nothing is focused after a refresh, not even in a @PreserveOnRefresh
view where the component instances are reused.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@totally-not-ai

Copy link
Copy Markdown
Contributor

@mcollovati Merged main. The only conflict was in the README feature list, where the new page reload bullet and the focus and blur bullet landed in the same place — both are kept.

The merge brought in the reload() DSL, which recreates the UI. Focus is tracked per UI, so nothing is focused after a reload; added reload_clearsFocus to ReloadPreserveOnRefreshTest to pin that, using a @PreserveOnRefresh view where the component instances are reused, so the assertion is about focus state rather than about the component being gone.

Full build green.

@mcollovati mcollovati changed the title Add blur simulation and implicit focus tracking feat: Add blur simulation and implicit focus tracking Sep 17, 2026
@mcollovati

Copy link
Copy Markdown
Contributor

@Artur- what should we do with this PR, after the changes introduced by vaadin/flow#25749? Ship it as it is with the problem of draining the queue? Or should we revise it after implementing vaadin/flow#221?

@Artur-

Artur- commented Sep 22, 2026

Copy link
Copy Markdown
Member

If the current implementation implements focus tracking without breaking anything else, let's ship it and make a follow-up PR. The current version could be backported to 25.3 as far as I understand, an updated version could not

@totally-not-ai

Copy link
Copy Markdown
Contributor

@Artur- Agreed — nothing else is pending here, so no further changes in this PR.

On not breaking anything: the full suite is green, and the only behaviour outside the focus paths is that the pending JavaScript queue is read when a server-side focus or blur call is actually queued. That case is narrowed by a containsPendingJavascript pre-check, and what the simulation takes off the queue is listed in a debug log under com.vaadin.browserless.FocusTracker.

On the backport: labelled target/25.3. The code only relies on public Flow API that is present there (containsPendingJavascript, dumpPendingJavaScriptInvocations), and the JavaScript it matches is what Focusable generates in 25.3 as well, so nothing in the detection is specific to the newer branch. Verified by building and running the whole junit6 suite, focus tests included, against the 25.3 Vaadin artifacts — all green. (The CDI regression module could not resolve vaadin-cdi for that version in this environment; it does not exercise focus.)

Follow-up work — a central place to handle the pending JavaScript queue, so other testers can react to scheduled JavaScript instead of it being consumed here — is tracked in #221, including why re-queueing the untouched invocations is not viable today.

@mcollovati
mcollovati merged commit 33e025f into main Sep 22, 2026
6 checks passed
@mcollovati
mcollovati deleted the bugfix/blur-issues branch September 22, 2026 06:39
@github-project-automation github-project-automation Bot moved this from 🔎Iteration reviews to Done in Vaadin Flow | Hilla | Kits ongoing work Sep 22, 2026
@vaadin-bot

Copy link
Copy Markdown
Collaborator

Hi @mstahv and @mcollovati, when i performed cherry-pick to this commit to 25.3, i have encountered the following issue. Can you take a look and pick it manually?
Error Message:
Error: Command failed: git cherry-pick 33e025f
error: could not apply 33e025f... feat: Add blur simulation and implicit focus tracking (#164)
hint: After resolving the conflicts, mark them with
hint: "git add/rm ", then run
hint: "git cherry-pick --continue".
hint: You can instead skip this commit with "git cherry-pick --skip".
hint: To abort and get back to the state before "git cherry-pick",
hint: run "git cherry-pick --abort".

@mcollovati

Copy link
Copy Markdown
Contributor

@totally-not-ai look at #164 (comment) and make the cherry-pick

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Development

Successfully merging this pull request may close these issues.

Blur and focus events should work

7 participants