From 621c88992375e14509495bde09aa666c3a63f356 Mon Sep 17 00:00:00 2001 From: "totally-not-ai[bot]" <290682512+totally-not-ai[bot]@users.noreply.github.com> Date: Wed, 16 Sep 2026 06:25:57 +0000 Subject: [PATCH] feat: identify focus and blur invocations with a command object Focusable.focus() and blur() schedule their JavaScript through the new Element.executeJs(JsCommand), so the pending invocation carries a typed FocusCommand or BlurCommand. A driver of the client side that cannot run JavaScript recognizes the invocation by the type of its command instead of by matching the text of the generated expression, which is the framework's script wrapped by executeJs. The expression and the parameters sent to a browser are unchanged. Part of https://github.com/vaadin/flow/issues/25734 --- .../vaadin/flow/component/BlurCommand.java | 48 ++++++++ .../vaadin/flow/component/FocusCommand.java | 105 ++++++++++++++++++ .../com/vaadin/flow/component/Focusable.java | 44 +------- .../flow/component/internal/UIInternals.java | 31 ++++++ .../java/com/vaadin/flow/dom/Element.java | 43 ++++++- .../java/com/vaadin/flow/dom/JsCommand.java | 82 ++++++++++++++ .../vaadin/flow/component/FocusableTest.java | 70 ++++++++++++ .../java/com/vaadin/flow/dom/ElementTest.java | 35 ++++++ 8 files changed, 410 insertions(+), 48 deletions(-) create mode 100644 flow-server/src/main/java/com/vaadin/flow/component/BlurCommand.java create mode 100644 flow-server/src/main/java/com/vaadin/flow/component/FocusCommand.java create mode 100644 flow-server/src/main/java/com/vaadin/flow/dom/JsCommand.java diff --git a/flow-server/src/main/java/com/vaadin/flow/component/BlurCommand.java b/flow-server/src/main/java/com/vaadin/flow/component/BlurCommand.java new file mode 100644 index 00000000000..bda5e81b2aa --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/component/BlurCommand.java @@ -0,0 +1,48 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package com.vaadin.flow.component; + +import com.vaadin.flow.dom.JsCommand; + +/** + * The command that {@link Focusable#blur()} schedules: remove focus from the + * element the invocation is scheduled on. + *

+ * The blur is server-initiated, which the generated script marks for the client + * so that the resulting {@link BlurNotifier.BlurEvent} reports + * {@code isFromClient() == false}. A driver that acts on this command instead + * of running the script is responsible for the same. + * + * @see FocusCommand + */ +public record BlurCommand() implements JsCommand { + + private static final String BLUR_SCRIPT = """ + setTimeout(() => { + try { + this._nextBlurIsFromClient = false; + this.blur(); + } finally { + this._nextBlurIsFromClient = true; + } + }, 0) + """; + + @Override + public String getExpression() { + return BLUR_SCRIPT; + } +} diff --git a/flow-server/src/main/java/com/vaadin/flow/component/FocusCommand.java b/flow-server/src/main/java/com/vaadin/flow/component/FocusCommand.java new file mode 100644 index 00000000000..13125560946 --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/component/FocusCommand.java @@ -0,0 +1,105 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package com.vaadin.flow.component; + +import java.util.List; + +import org.jspecify.annotations.Nullable; +import tools.jackson.databind.node.ObjectNode; + +import com.vaadin.flow.dom.Element; +import com.vaadin.flow.dom.JsCommand; + +/** + * The command that {@link Focusable#focus(FocusOption...)} schedules: focus the + * element the invocation is scheduled on, with the given options. + *

+ * The focus is server-initiated, which the generated script marks for the + * client so that the resulting {@link FocusNotifier.FocusEvent} reports + * {@code isFromClient() == false}. A driver that acts on this command instead + * of running the script is responsible for the same. + * + * @param options + * the options passed to {@link Focusable#focus(FocusOption...)}, in + * the order they were given; only the last {@link FocusOption} of + * each kind reaches the browser + * @see BlurCommand + */ +public record FocusCommand(List options) implements JsCommand { + + private static final String FOCUS_SCRIPT = """ + setTimeout(() => { + try { + this._nextFocusIsFromClient = false; + this.focus(); + } finally { + this._nextFocusIsFromClient = true; + } + }, 0) + """; + + private static final String FOCUS_WITH_OPTIONS_SCRIPT = """ + setTimeout(() => { + try { + this._nextFocusIsFromClient = false; + this.focus($0); + } finally { + this._nextFocusIsFromClient = true; + } + }, 0) + """; + + /** + * Creates a focus command with the given options. + * + * @param options + * the focus options, not null and with no + * null elements + */ + public FocusCommand { + options = List.copyOf(options); + } + + /** + * Creates a focus command with the given options. + * + * @param options + * zero or more focus options, with no null elements + */ + public FocusCommand(FocusOption... options) { + this(List.of(options)); + } + + @Override + public String getExpression() { + return optionsJson() == null ? FOCUS_SCRIPT : FOCUS_WITH_OPTIONS_SCRIPT; + } + + @Override + public List getParameters() { + ObjectNode json = optionsJson(); + return json == null ? List.of() : List.of(json); + } + + /** + * The options as the browser receives them, or null when every + * option is at its default and {@link Element#focus()} is called without + * arguments. + */ + private @Nullable ObjectNode optionsJson() { + return FocusOption.buildOptions(options.toArray(new FocusOption[0])); + } +} diff --git a/flow-server/src/main/java/com/vaadin/flow/component/Focusable.java b/flow-server/src/main/java/com/vaadin/flow/component/Focusable.java index c6ac930cac3..4ef0ade9c0a 100644 --- a/flow-server/src/main/java/com/vaadin/flow/component/Focusable.java +++ b/flow-server/src/main/java/com/vaadin/flow/component/Focusable.java @@ -15,10 +15,6 @@ */ package com.vaadin.flow.component; -import tools.jackson.databind.node.ObjectNode; - -import com.vaadin.flow.dom.Element; - /** * Represents a component that can gain and lose focus. * @@ -134,34 +130,7 @@ default int getTabIndex() { * @since 25.0 */ default void focus(FocusOption... options) { - Element element = getElement(); - ObjectNode json = FocusOption.buildOptions(options); - - if (json == null) { - // No options, call focus() without arguments - element.executeJs(""" - setTimeout(() => { - try { - this._nextFocusIsFromClient = false; - this.focus(); - } finally { - this._nextFocusIsFromClient = true; - } - }, 0) - """); - } else { - // Call focus with options object passed as parameter - element.executeJs(""" - setTimeout(() => { - try { - this._nextFocusIsFromClient = false; - this.focus($0); - } finally { - this._nextFocusIsFromClient = true; - } - }, 0) - """, json); - } + getElement().executeJs(new FocusCommand(options)); } // for binary compatibility with the previous Vaadin versions @@ -190,16 +159,7 @@ default void focus() { * at MDN */ default void blur() { - getElement().executeJs(""" - setTimeout(() => { - try { - this._nextBlurIsFromClient = false; - this.blur(); - } finally { - this._nextBlurIsFromClient = true; - } - }, 0) - """); + getElement().executeJs(new BlurCommand()); } /** diff --git a/flow-server/src/main/java/com/vaadin/flow/component/internal/UIInternals.java b/flow-server/src/main/java/com/vaadin/flow/component/internal/UIInternals.java index e1be9477138..6ad24457ab9 100644 --- a/flow-server/src/main/java/com/vaadin/flow/component/internal/UIInternals.java +++ b/flow-server/src/main/java/com/vaadin/flow/component/internal/UIInternals.java @@ -62,6 +62,7 @@ import com.vaadin.flow.di.Instantiator; import com.vaadin.flow.dom.Element; import com.vaadin.flow.dom.ElementUtil; +import com.vaadin.flow.dom.JsCommand; import com.vaadin.flow.dom.impl.BasicElementStateProvider; import com.vaadin.flow.function.DeploymentConfiguration; import com.vaadin.flow.internal.ActiveStyleSheetTracker; @@ -127,6 +128,7 @@ public class UIInternals implements Serializable { public static class JavaScriptInvocation implements Serializable { private final String expression; private final List parameters = new ArrayList<>(); + private final @Nullable JsCommand command; /** * Creates a new invocation. @@ -138,6 +140,23 @@ public static class JavaScriptInvocation implements Serializable { * @since 25.0 */ public JavaScriptInvocation(String expression, Object... parameters) { + this((JsCommand) null, expression, parameters); + } + + /** + * Creates a new invocation for the given command, whose expression and + * parameters the caller has already resolved. + * + * @param command + * the command that this invocation performs, or + * null if the invocation is plain JavaScript + * @param expression + * the expression to invoke + * @param parameters + * a list of parameters to use when invoking the script + */ + public JavaScriptInvocation(@Nullable JsCommand command, + String expression, Object... parameters) { /* * To ensure attached elements are actually attached, the parameters * won't be serialized until the phase the UIDL message is created. @@ -151,6 +170,7 @@ public JavaScriptInvocation(String expression, Object... parameters) { this.expression = expression; Collections.addAll(this.parameters, parameters); + this.command = command; } /** @@ -170,6 +190,17 @@ public String getExpression() { public List getParameters() { return Collections.unmodifiableList(parameters); } + + /** + * Gets the command that this invocation performs, for a caller that + * acts on the invocation instead of running its JavaScript. + * + * @return the command, or null if the invocation is plain + * JavaScript with no command describing it + */ + public @Nullable JsCommand getCommand() { + return command; + } } /** diff --git a/flow-server/src/main/java/com/vaadin/flow/dom/Element.java b/flow-server/src/main/java/com/vaadin/flow/dom/Element.java index 89a061de951..00f89d2d02a 100644 --- a/flow-server/src/main/java/com/vaadin/flow/dom/Element.java +++ b/flow-server/src/main/java/com/vaadin/flow/dom/Element.java @@ -1850,8 +1850,8 @@ public PendingJavaScriptResult callJsFunction(String functionName, System.arraycopy(arguments, 0, jsParameters, 1, arguments.length); } - return scheduleJavaScriptInvocation("return $0." + functionName + "(" - + paramPlaceholderString + ")", jsParameters); + return scheduleJavaScriptInvocation(null, "return $0." + functionName + + "(" + paramPlaceholderString + ")", jsParameters); } /** @@ -1924,6 +1924,36 @@ public PendingJavaScriptResult callJsFunction(String functionName, */ public PendingJavaScriptResult executeJs(String expression, Object... parameters) { + return scheduleExecuteJs(null, expression, parameters); + } + + /** + * Asynchronously runs the JavaScript of the given command in the browser in + * the context of this element, exactly as + * {@link #executeJs(String, Object...)} runs the command's + * {@link JsCommand#getExpression() expression} with its + * {@link JsCommand#getParameters() parameters}. + *

+ * What the command adds is server-side: it stays with the invocation in the + * pending JavaScript queue of the UI, so that a driver of the client side + * that can not run JavaScript can recognize the invocation by the type of + * its command instead of by the text of the generated expression. See + * {@link JsCommand}. + * + * @param command + * the command to run, not null + * @return a pending result that can be used to get a value returned from + * the expression + */ + public PendingJavaScriptResult executeJs(JsCommand command) { + Objects.requireNonNull(command, "Command cannot be null"); + return scheduleExecuteJs(command, command.getExpression(), + command.getParameters().toArray()); + } + + private PendingJavaScriptResult scheduleExecuteJs( + @Nullable JsCommand command, String expression, + Object[] parameters) { // Add "this" as the last parameter Object[] wrappedParameters; @@ -1939,7 +1969,7 @@ public PendingJavaScriptResult executeJs(String expression, String wrappedExpression = "return (async function() { " + expression + "}).apply($" + parameters.length + ")"; - return scheduleJavaScriptInvocation(wrappedExpression, + return scheduleJavaScriptInvocation(command, wrappedExpression, wrappedParameters); } @@ -2005,11 +2035,12 @@ public Registration addJsInitializer(String expression, } private PendingJavaScriptResult scheduleJavaScriptInvocation( - String expression, Object[] parameters) { + @Nullable JsCommand command, String expression, + Object[] parameters) { StateNode node = getNode(); - JavaScriptInvocation invocation = new JavaScriptInvocation(expression, - parameters); + JavaScriptInvocation invocation = new JavaScriptInvocation(command, + expression, parameters); PendingJavaScriptInvocation pending = new PendingJavaScriptInvocation( node, invocation); diff --git a/flow-server/src/main/java/com/vaadin/flow/dom/JsCommand.java b/flow-server/src/main/java/com/vaadin/flow/dom/JsCommand.java new file mode 100644 index 00000000000..abf54c4d172 --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/dom/JsCommand.java @@ -0,0 +1,82 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package com.vaadin.flow.dom; + +import java.io.Serializable; +import java.util.List; + +/** + * A typed description of a client-side operation, together with the JavaScript + * that performs it in a browser. + *

+ * A command is scheduled with {@link Element#executeJs(JsCommand)} and travels + * with the invocation into the pending JavaScript queue of the UI, where + * {@link com.vaadin.flow.component.internal.UIInternals.JavaScriptInvocation#getCommand()} + * hands it back. The client receives exactly the expression and the parameters + * that the equivalent {@link Element#executeJs(String, Object...)} call would + * send; the command itself never leaves the server. + *

+ * The command exists for a driver of the client side that is not a browser and + * can not run JavaScript — a browserless test framework, a native bridge. Such + * a driver takes the queue at a request boundary, in order, and acts on the + * invocations it recognizes: + * + *

+ * for (PendingJavaScriptInvocation pending : internals
+ *         .dumpPendingJavaScriptInvocations()) {
+ *     switch (pending.getInvocation().getCommand()) {
+ *     case FocusCommand focus -> focus(Element.get(pending.getOwner()));
+ *     case BlurCommand blur -> blur(Element.get(pending.getOwner()));
+ *     case null, default -> recordUnhandledJavaScript(pending);
+ *     }
+ * }
+ * 
+ * + * Without a command, the only thing that identifies an invocation is the text + * of its expression — and that text is the framework's script wrapped by + * {@code executeJs}, an implementation detail that a driver would have to match + * as a substring and that changes silently underneath it. + *

+ * An implementation is a value: immutable, {@link Serializable}, with the + * arguments of the operation as typed members so that a driver never has to + * read the generated JavaScript. A record is the natural shape. The target of + * the operation is not part of the command: it is the element the invocation + * was scheduled on, available as the owner of the pending invocation. + * + * @see Element#executeJs(JsCommand) + */ +public interface JsCommand extends Serializable { + + /** + * Gets the JavaScript expression that performs this command in a browser. + * The expression is the one that would be passed to + * {@link Element#executeJs(String, Object...)}: the element it is scheduled + * on is available as this and the parameters as + * $0, $1, … + * + * @return the JavaScript expression, not null + */ + String getExpression(); + + /** + * Gets the parameters that the expression references positionally. + * + * @return the parameters, empty by default, not null + */ + default List getParameters() { + return List.of(); + } +} diff --git a/flow-server/src/test/java/com/vaadin/flow/component/FocusableTest.java b/flow-server/src/test/java/com/vaadin/flow/component/FocusableTest.java index 59f22075957..5b65da702a6 100644 --- a/flow-server/src/test/java/com/vaadin/flow/component/FocusableTest.java +++ b/flow-server/src/test/java/com/vaadin/flow/component/FocusableTest.java @@ -15,6 +15,7 @@ */ package com.vaadin.flow.component; +import java.util.ArrayList; import java.util.List; import org.junit.jupiter.api.Test; @@ -22,6 +23,8 @@ import com.vaadin.flow.component.FocusOption.FocusVisible; import com.vaadin.flow.component.FocusOption.PreventScroll; import com.vaadin.flow.component.internal.PendingJavaScriptInvocation; +import com.vaadin.flow.dom.Element; +import com.vaadin.flow.dom.JsCommand; import com.vaadin.tests.util.MockUI; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -266,4 +269,71 @@ void focus_withoutOptions_generatesCorrectJS() { assertEquals(1, params.size(), "Should have exactly 1 wrapped parameter (no user-provided parameters)"); } + + @Test + void focus_invocationCarriesFocusCommandWithTheOptions() { + ui.add(component); + component.focus(FocusVisible.VISIBLE, PreventScroll.ENABLED); + + assertEquals( + new FocusCommand(FocusVisible.VISIBLE, PreventScroll.ENABLED), + dumpSingleCommand(), + "focus() should be identifiable by its command, options included"); + } + + @Test + void focusWithoutOptions_invocationCarriesFocusCommandWithNoOptions() { + ui.add(component); + component.focus(); + + assertEquals(new FocusCommand(), dumpSingleCommand()); + } + + @Test + void blur_invocationCarriesBlurCommand() { + ui.add(component); + component.blur(); + + assertEquals(new BlurCommand(), dumpSingleCommand()); + } + + @Test + void pendingInvocations_dispatchedByCommandType_plainJavaScriptLeftIntact() { + ui.add(component); + component.focus(PreventScroll.ENABLED); + component.getElement().executeJs("this.scrollTop = 0"); + component.blur(); + + // What a driver of the client side that cannot run JavaScript does: + // take the queue once, in order, and act on what it recognizes + List log = new ArrayList<>(); + List unhandledJs = new ArrayList<>(); + for (PendingJavaScriptInvocation pending : ui + .dumpPendingJsInvocations()) { + Element target = Element.get(pending.getOwner()); + switch (pending.getInvocation().getCommand()) { + case FocusCommand focus -> + log.add("focus " + target.getTag() + " " + focus.options()); + case BlurCommand blur -> log.add("blur " + target.getTag()); + case null, default -> { + log.add("unhandled"); + unhandledJs.add(pending.getInvocation().getExpression()); + } + } + } + + assertEquals(List.of("focus div [ENABLED]", "unhandled", "blur div"), + log, "invocations should be dispatched by type, in order"); + assertEquals(1, unhandledJs.size(), + "the application JavaScript should be left for the driver to report"); + assertTrue(unhandledJs.get(0).contains("this.scrollTop = 0"), + "the unhandled invocation should be the application JavaScript"); + } + + private JsCommand dumpSingleCommand() { + List invocations = ui + .dumpPendingJsInvocations(); + assertEquals(1, invocations.size()); + return invocations.get(0).getInvocation().getCommand(); + } } diff --git a/flow-server/src/test/java/com/vaadin/flow/dom/ElementTest.java b/flow-server/src/test/java/com/vaadin/flow/dom/ElementTest.java index 346913d9e13..42f4adb76d5 100644 --- a/flow-server/src/test/java/com/vaadin/flow/dom/ElementTest.java +++ b/flow-server/src/test/java/com/vaadin/flow/dom/ElementTest.java @@ -2662,6 +2662,41 @@ void callFunctionOnSubProperty() { assertPendingJs(ui, "return $0.property.other.method()", element); } + @Test + void executeJsWithCommand_sameInvocationAsTheStringForm() { + UI ui = new MockUI(); + Element element = ElementFactory.createDiv(); + ui.getElement().appendChild(element); + + element.executeJs(new TestCommand("foo")); + element.executeJs("this.method($0)", "foo"); + ui.getInternals().getStateTree().runExecutionsBeforeClientResponse(); + + List pendingJs = ui.getInternals() + .dumpPendingJavaScriptInvocations(); + assertEquals(2, pendingJs.size()); + JavaScriptInvocation fromCommand = pendingJs.get(0).getInvocation(); + JavaScriptInvocation fromString = pendingJs.get(1).getInvocation(); + + assertInvocationEquals(fromString, fromCommand); + assertEquals(new TestCommand("foo"), fromCommand.getCommand(), + "the command should travel with the invocation"); + assertNull(fromString.getCommand(), + "plain executeJs should have no command"); + } + + private record TestCommand(String value) implements JsCommand { + @Override + public String getExpression() { + return "this.method($0)"; + } + + @Override + public List getParameters() { + return List.of(value); + } + } + @Test void addJsInitializer_nullExpression_throws() { Element element = ElementFactory.createDiv();