feat: Add blur simulation and implicit focus tracking - #164
Conversation
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>
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>
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.
| // receiving the invocations would | ||
| ui.getInternals().getStateTree() | ||
| .runExecutionsBeforeClientResponse(); | ||
| for (PendingJavaScriptInvocation invocation : ui.getInternals() |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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:
FocusTrackernow implementsSerializablefocusedis 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 anIllegalStateException(@Artur-) focus()/blur()fail fast when the component is notFocusable, 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
@sincetag dropped
Five tests in BlurSimulationTest cover the new edge cases (blur without focus, non-focusable component, read-only, disabled, detached). Full build green.
- 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
|
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 |
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>
|
@Artur- Status: no open questions from my side, and nothing here waits for Flow. All review comments are addressed:
On the JavaScript mechanism: agreed, it is not a blocker. Detection matches the JS that Ready for another look. |
| "Parameter component: invalid value " + component | ||
| + ": not a Focusable: " + component.getClass()); |
There was a problem hiding this comment.
This error message looks a bit cryptic. Make it easier to read like <component class> is not Focusable
|
The pending JavaScript invocation check before purge does not resolve the problem of vanishing simulations, but it is better than nothing. |
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>
|
@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 Why re-queueing the untouched invocations is not an option is recorded in #221, together with the current state: |
|
@totally-not-ai merge main and resolve the conflicts |
# Conflicts: # README.md
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>
|
@mcollovati Merged The merge brought in the Full build green. |
|
@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? |
|
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 |
|
@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 On the backport: labelled 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. |
|
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? |
|
@totally-not-ai look at #164 (comment) and make the cherry-pick |
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.
ComponentTesteralso gets explicitfocus(),blur()andisFocused()methods, and server-sideFocusable.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 sharedClickableimplementation) andsetValueAsUser(...)first move focus to the component, which blurs the previously focused one. Blur runs before the click is handled, like in a browser.Focusable.focus()orblur()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 undercom.vaadin.browserless.FocusTracker. Handling the queue centrally is tracked in #221.New behaviour:
FocusTrackerkeeps the focused component per UI. It firesfocus/blurDOM events as if they came from the client, and mirrors Flow's markers so that a server-initiated focus or blur reportsisFromClient() == false.Focusable.focus()/blur()calls are applied at the end of a focus-tracked interaction, onroundTrip(), and whenisFocused()is called.focus()andblur()fail fast:IllegalArgumentExceptionif the component is notFocusable,IllegalStateExceptionif it is disabled or not attached. Read-only fields can still be focused.Focusablecomponents take focus. Interacting with anything else blurs the previous component and leaves nothing focused, like focus falling back to the document body.@PreserveOnRefreshview.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.
API Changes
com.vaadin.browserless.ComponentTester
Test summary
Implicit focus on interactions:
Explicit focus API:
focus()/blur()fail with a readable message when the component is notFocusableServer-side Focusable calls:
focus()from a value change listener moves focus and blurs the previous field, not from clientfocus(FocusOption...)detected as wellblur()fires the blur event and clears focus, not from clientroundTrip()Page reload:
@PreserveOnRefreshview