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 01/57] 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();
From db6feb04628a4c997929966f1bb64a6e3bbf1838 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:37:17 +0000
Subject: [PATCH 02/57] feat: invoke focus and blur through a JS invoker
interface
Replaces the per-operation command records with the invoker shape from
#10759: the JavaScript is a constant on a FocusJs interface, obtained
through the new Element.getJsInvoker(Class) and called as a Java method.
The scheduled invocation carries a JsInvokerCall, so a driver of the
client side can dispatch on the interface and the method, or run the
call on its own implementation of the same interface.
Kept as an alternative to the parent branch for comparison. 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/FocusJs.java | 86 ++++++++++++
.../com/vaadin/flow/component/Focusable.java | 12 +-
.../java/com/vaadin/flow/dom/Element.java | 78 +++++++++++
.../com/vaadin/flow/dom/JsExpression.java | 48 +++++++
.../com/vaadin/flow/dom/JsInvokerCall.java | 126 ++++++++++++++++++
.../vaadin/flow/component/FocusableTest.java | 74 +++++++---
8 files changed, 401 insertions(+), 176 deletions(-)
delete mode 100644 flow-server/src/main/java/com/vaadin/flow/component/BlurCommand.java
delete mode 100644 flow-server/src/main/java/com/vaadin/flow/component/FocusCommand.java
create mode 100644 flow-server/src/main/java/com/vaadin/flow/component/FocusJs.java
create mode 100644 flow-server/src/main/java/com/vaadin/flow/dom/JsExpression.java
create mode 100644 flow-server/src/main/java/com/vaadin/flow/dom/JsInvokerCall.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
deleted file mode 100644
index bda5e81b2aa..00000000000
--- a/flow-server/src/main/java/com/vaadin/flow/component/BlurCommand.java
+++ /dev/null
@@ -1,48 +0,0 @@
-/*
- * 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
deleted file mode 100644
index 13125560946..00000000000
--- a/flow-server/src/main/java/com/vaadin/flow/component/FocusCommand.java
+++ /dev/null
@@ -1,105 +0,0 @@
-/*
- * 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/FocusJs.java b/flow-server/src/main/java/com/vaadin/flow/component/FocusJs.java
new file mode 100644
index 00000000000..b1901e76982
--- /dev/null
+++ b/flow-server/src/main/java/com/vaadin/flow/component/FocusJs.java
@@ -0,0 +1,86 @@
+/*
+ * 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.io.Serializable;
+
+import tools.jackson.databind.node.ObjectNode;
+
+import com.vaadin.flow.dom.Element;
+import com.vaadin.flow.dom.JsExpression;
+
+/**
+ * The client-side operations behind {@link Focusable}, as an invoker interface
+ * for {@link Element#getJsInvoker(Class)}.
+ *
+ * Focus and blur are marked as server-initiated for the client, so that the
+ * resulting event reports {@code isFromClient() == false}. A driver of the
+ * client side that implements this interface instead of running the scripts is
+ * responsible for the same.
+ *
+ * An invoker interface extends {@link Serializable}, like everything else a
+ * component can hold on to.
+ */
+public interface FocusJs extends Serializable {
+
+ /**
+ * Focuses the element with browser default options.
+ */
+ @JsExpression("""
+ setTimeout(() => {
+ try {
+ this._nextFocusIsFromClient = false;
+ this.focus();
+ } finally {
+ this._nextFocusIsFromClient = true;
+ }
+ }, 0)
+ """)
+ void focus();
+
+ /**
+ * Focuses the element with the given options.
+ *
+ * @param options
+ * the options of the browser's focus function
+ */
+ @JsExpression("""
+ setTimeout(() => {
+ try {
+ this._nextFocusIsFromClient = false;
+ this.focus($0);
+ } finally {
+ this._nextFocusIsFromClient = true;
+ }
+ }, 0)
+ """)
+ void focus(ObjectNode options);
+
+ /**
+ * Removes focus from the element.
+ */
+ @JsExpression("""
+ setTimeout(() => {
+ try {
+ this._nextBlurIsFromClient = false;
+ this.blur();
+ } finally {
+ this._nextBlurIsFromClient = true;
+ }
+ }, 0)
+ """)
+ void blur();
+}
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 4ef0ade9c0a..9de143f0a59 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,6 +15,8 @@
*/
package com.vaadin.flow.component;
+import tools.jackson.databind.node.ObjectNode;
+
/**
* Represents a component that can gain and lose focus.
*
@@ -130,7 +132,13 @@ default int getTabIndex() {
* @since 25.0
*/
default void focus(FocusOption... options) {
- getElement().executeJs(new FocusCommand(options));
+ FocusJs focusJs = getElement().getJsInvoker(FocusJs.class);
+ ObjectNode json = FocusOption.buildOptions(options);
+ if (json == null) {
+ focusJs.focus();
+ } else {
+ focusJs.focus(json);
+ }
}
// for binary compatibility with the previous Vaadin versions
@@ -159,7 +167,7 @@ default void focus() {
* at MDN
*/
default void blur() {
- getElement().executeJs(new BlurCommand());
+ getElement().getJsInvoker(FocusJs.class).blur();
}
/**
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 00f89d2d02a..75a280040f7 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
@@ -16,6 +16,9 @@
package com.vaadin.flow.dom;
import java.io.Serializable;
+import java.lang.reflect.InvocationHandler;
+import java.lang.reflect.Method;
+import java.lang.reflect.Proxy;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
@@ -1951,6 +1954,81 @@ public PendingJavaScriptResult executeJs(JsCommand command) {
command.getParameters().toArray());
}
+ /**
+ * Gets an invoker for the JavaScript expressions that the given interface
+ * declares, bound to this element.
+ *
+ * Each method of the interface is annotated with the {@link JsExpression}
+ * it runs. Calling a method schedules that expression the way
+ * {@link #executeJs(String, Object...)} would, with the method arguments as
+ * its parameters and this element as this:
+ *
+ *
+ * public interface GreeterJs {
+ * @JsExpression("window.alert($0)")
+ * void showGreeting(String greeting);
+ * }
+ *
+ * element.getJsInvoker(GreeterJs.class).showGreeting("Hello");
+ *
+ *
+ * The expression is a constant of the interface rather than a string built
+ * at the call site, and the scheduled invocation carries the call as a
+ * {@link JsInvokerCall} so that a driver of the client side can recognize
+ * it, or run it on its own implementation of the same interface.
+ *
+ * A method returns either void or
+ * {@link PendingJavaScriptResult}.
+ *
+ * @param
+ * the invoker interface type
+ * @param invokerType
+ * the invoker interface, not null
+ * @return an invoker bound to this element, not null
+ */
+ @SuppressWarnings("unchecked")
+ public T getJsInvoker(Class invokerType) {
+ Objects.requireNonNull(invokerType, "Invoker type cannot be null");
+ if (!invokerType.isInterface()) {
+ throw new IllegalArgumentException(
+ invokerType.getName() + " is not an interface");
+ }
+ return (T) Proxy.newProxyInstance(invokerType.getClassLoader(),
+ new Class>[] { invokerType },
+ new JsInvokerHandler(this, invokerType));
+ }
+
+ /**
+ * Turns a call on a JS invoker interface into a scheduled invocation that
+ * carries the call.
+ */
+ private record JsInvokerHandler(Element element,
+ Class> invokerType) implements InvocationHandler, Serializable {
+
+ @Override
+ public Object invoke(Object proxy, Method method, Object[] args)
+ throws Throwable {
+ if (method.getDeclaringClass() == Object.class) {
+ return method.invoke(this, args);
+ }
+ List arguments = args == null ? List.of()
+ : Arrays.asList(args);
+ PendingJavaScriptResult result = element
+ .executeJs(new JsInvokerCall(invokerType, method.getName(),
+ arguments));
+ if (method.getReturnType() == void.class) {
+ return null;
+ }
+ if (method.getReturnType()
+ .isAssignableFrom(PendingJavaScriptResult.class)) {
+ return result;
+ }
+ throw new IllegalStateException("Method " + method.getName()
+ + " of " + invokerType.getName()
+ + " must return void or PendingJavaScriptResult");
+ }
+ }
+
private PendingJavaScriptResult scheduleExecuteJs(
@Nullable JsCommand command, String expression,
Object[] parameters) {
diff --git a/flow-server/src/main/java/com/vaadin/flow/dom/JsExpression.java b/flow-server/src/main/java/com/vaadin/flow/dom/JsExpression.java
new file mode 100644
index 00000000000..7d3475f895d
--- /dev/null
+++ b/flow-server/src/main/java/com/vaadin/flow/dom/JsExpression.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.dom;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * The JavaScript that a method of a JS invoker interface runs, as a constant
+ * expression.
+ *
+ * The annotated method is called through {@link Element#getJsInvoker(Class)}.
+ * Its arguments are the parameters of the expression, referenced positionally
+ * as $0, $1, …, and the element the invoker
+ * was obtained from is this — the same contract as
+ * {@link Element#executeJs(String, Object...)}, except that the expression is a
+ * constant of the interface instead of a string built at the call site.
+ *
+ * @see Element#getJsInvoker(Class)
+ */
+@Documented
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface JsExpression {
+
+ /**
+ * The JavaScript expression to run.
+ *
+ * @return the expression
+ */
+ String value();
+}
diff --git a/flow-server/src/main/java/com/vaadin/flow/dom/JsInvokerCall.java b/flow-server/src/main/java/com/vaadin/flow/dom/JsInvokerCall.java
new file mode 100644
index 00000000000..edcf5d4ed45
--- /dev/null
+++ b/flow-server/src/main/java/com/vaadin/flow/dom/JsInvokerCall.java
@@ -0,0 +1,126 @@
+/*
+ * 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.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * A call made through {@link Element#getJsInvoker(Class)}: which invoker
+ * interface, which method of it, and the arguments that were passed.
+ *
+ * This is the {@link JsCommand} that the invoker schedules, so the call is what
+ * a driver of the client side sees in the pending JavaScript queue. It can
+ * dispatch on the interface and the method name, or hand the call to an
+ * implementation of the same interface with {@link #invokeOn(Object)} and let
+ * Java do the dispatching:
+ *
+ *
+ * if (call.invokerType() == FocusJs.class) {
+ * call.invokeOn(new FocusSimulation(Element.get(pending.getOwner())));
+ * }
+ *
+ *
+ * @param invokerType
+ * the invoker interface the call was made on
+ * @param methodName
+ * the name of the called method
+ * @param arguments
+ * the arguments of the call, in declaration order
+ */
+public record JsInvokerCall(Class> invokerType, String methodName,
+ List arguments) implements JsCommand {
+
+ /**
+ * Creates a call of the given method of the given invoker interface.
+ *
+ * @param invokerType
+ * the invoker interface, not null
+ * @param methodName
+ * the name of the called method, not null
+ * @param arguments
+ * the arguments of the call, not null
+ */
+ public JsInvokerCall {
+ Objects.requireNonNull(invokerType, "Invoker type cannot be null");
+ Objects.requireNonNull(methodName, "Method name cannot be null");
+ arguments = List.copyOf(arguments);
+ }
+
+ @Override
+ public String getExpression() {
+ JsExpression annotation = resolveMethod()
+ .getAnnotation(JsExpression.class);
+ if (annotation == null) {
+ throw new IllegalStateException(
+ "Method " + methodName + " of " + invokerType.getName()
+ + " is not annotated with @JsExpression");
+ }
+ return annotation.value();
+ }
+
+ @Override
+ public List getParameters() {
+ return arguments;
+ }
+
+ /**
+ * Runs this call on an implementation of the invoker interface, which is
+ * how a driver of the client side reproduces it without running the
+ * JavaScript.
+ *
+ * @param implementation
+ * an implementation of {@link #invokerType()}, not
+ * null
+ * @return the value returned by the implementation, or null
+ * for a void method
+ */
+ public Object invokeOn(Object implementation) {
+ if (!invokerType.isInstance(implementation)) {
+ throw new IllegalArgumentException(
+ implementation.getClass().getName() + " does not implement "
+ + invokerType.getName());
+ }
+ try {
+ return resolveMethod().invoke(implementation, arguments.toArray());
+ } catch (IllegalAccessException | InvocationTargetException e) {
+ throw new IllegalStateException(
+ "Could not run " + methodName + " on " + implementation, e);
+ }
+ }
+
+ /**
+ * Finds the called method by name and argument count. Overloads that differ
+ * only in parameter types are not distinguishable this way, which is a
+ * limitation of the prototype rather than of the idea.
+ */
+ private Method resolveMethod() {
+ List candidates = Arrays.stream(invokerType.getMethods())
+ .filter(method -> method.getName().equals(methodName)
+ && method.getParameterCount() == arguments.size())
+ .toList();
+ if (candidates.size() != 1) {
+ throw new IllegalStateException("Expected exactly one method named "
+ + methodName + " with " + arguments.size()
+ + " parameters in " + invokerType.getName() + ", found "
+ + candidates.size());
+ }
+ return candidates.get(0);
+ }
+}
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 5b65da702a6..da83f74d3ce 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
@@ -19,12 +19,14 @@
import java.util.List;
import org.junit.jupiter.api.Test;
+import tools.jackson.databind.node.ObjectNode;
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.flow.dom.JsInvokerCall;
import com.vaadin.tests.util.MockUI;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -271,59 +273,66 @@ void focus_withoutOptions_generatesCorrectJS() {
}
@Test
- void focus_invocationCarriesFocusCommandWithTheOptions() {
+ void focus_invocationCarriesTheInvokerCallWithTheOptions() {
ui.add(component);
- component.focus(FocusVisible.VISIBLE, PreventScroll.ENABLED);
+ component.focus(PreventScroll.ENABLED);
- assertEquals(
- new FocusCommand(FocusVisible.VISIBLE, PreventScroll.ENABLED),
- dumpSingleCommand(),
- "focus() should be identifiable by its command, options included");
+ JsInvokerCall call = (JsInvokerCall) dumpSingleCommand();
+ assertEquals(FocusJs.class, call.invokerType());
+ assertEquals("focus", call.methodName());
+ assertEquals("{\"preventScroll\":true}",
+ call.arguments().get(0).toString(),
+ "the options reach the driver as the JSON the browser gets");
}
@Test
- void focusWithoutOptions_invocationCarriesFocusCommandWithNoOptions() {
+ void focusWithoutOptions_invocationCarriesTheNoArgumentCall() {
ui.add(component);
component.focus();
- assertEquals(new FocusCommand(), dumpSingleCommand());
+ assertEquals(new JsInvokerCall(FocusJs.class, "focus", List.of()),
+ dumpSingleCommand());
}
@Test
- void blur_invocationCarriesBlurCommand() {
+ void blur_invocationCarriesTheBlurCall() {
ui.add(component);
component.blur();
- assertEquals(new BlurCommand(), dumpSingleCommand());
+ assertEquals(new JsInvokerCall(FocusJs.class, "blur", List.of()),
+ dumpSingleCommand());
}
@Test
- void pendingInvocations_dispatchedByCommandType_plainJavaScriptLeftIntact() {
+ void pendingInvocations_runOnAnImplementationOfTheInvoker_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
+ // take the queue once, in order, and let Java dispatch the calls it
+ // recognizes onto its own implementation of the invoker interface
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 -> {
+ JsCommand command = pending.getInvocation().getCommand();
+ if (command instanceof JsInvokerCall call
+ && call.invokerType() == FocusJs.class) {
+ call.invokeOn(new FocusSimulation(
+ Element.get(pending.getOwner()), log));
+ } else {
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(
+ List.of("focus div {\"preventScroll\":true}", "unhandled",
+ "blur div"),
+ log,
+ "calls should be dispatched onto the implementation, 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"),
@@ -336,4 +345,27 @@ private JsCommand dumpSingleCommand() {
assertEquals(1, invocations.size());
return invocations.get(0).getInvocation().getCommand();
}
+
+ /**
+ * What a browserless driver would register for {@link FocusJs}: the
+ * server-side effect of the operations, with no JavaScript involved.
+ */
+ private record FocusSimulation(Element target,
+ List log) implements FocusJs {
+
+ @Override
+ public void focus() {
+ log.add("focus " + target.getTag());
+ }
+
+ @Override
+ public void focus(ObjectNode options) {
+ log.add("focus " + target.getTag() + " " + options);
+ }
+
+ @Override
+ public void blur() {
+ log.add("blur " + target.getTag());
+ }
+ }
}
From f3c12bb3814991580c3e503e32df17526c539373 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 07:22:20 +0000
Subject: [PATCH 03/57] feat: run declared JavaScript without compiling it in
the browser
An interface annotated with @JsInvoker declares the JavaScript the server
can invoke as @JsExpression constants, and Element.getJsInvoker(Class)
calls it as a Java method. The build collects the declarations into the
bundle, the response names the interface and the method instead of
carrying a script, and the client runs the function from the bundle.
Nothing is compiled in the browser, so the call needs no unsafe-eval, and
the JavaScript an application can be made to run is known when it is
built.
Focusable.focus() and blur() are the first users. A bundle that was built
without the declared JavaScript is rebuilt, and the dev mode class finder
knows the annotation, so the functions are there when a call looks them
up.
Part of #10759
Part of https://github.com/vaadin/flow/issues/25734
---
.../server/frontend/BundleValidationUtil.java | 43 ++++++
.../flow/server/frontend/NodeTasks.java | 3 +
.../frontend/TaskGenerateBootstrap.java | 2 +
.../frontend/TaskGenerateJsInvokers.java | 125 ++++++++++++++++++
.../TaskGenerateWebComponentBootstrap.java | 2 +
.../server/frontend/BundleValidationTest.java | 24 ++++
.../frontend/TaskGenerateJsInvokersTest.java | 95 +++++++++++++
.../client/flow/ExecuteJavaScriptProcessor.ts | 85 +++++++++++-
.../flow/ExecuteJavaScriptProcessorTests.ts | 84 ++++++++++++
.../com/vaadin/flow/component/FocusJs.java | 2 +
.../flow/component/internal/UIInternals.java | 33 ++---
.../java/com/vaadin/flow/dom/Element.java | 101 +++++++-------
.../java/com/vaadin/flow/dom/JsCommand.java | 82 ------------
.../java/com/vaadin/flow/dom/JsInvoker.java | 43 ++++++
.../com/vaadin/flow/dom/JsInvokerCall.java | 65 +++++++--
.../vaadin/flow/internal/FrontendUtils.java | 7 +
.../flow/server/communication/UidlWriter.java | 48 +++++++
.../com/vaadin/flow/shared/JsonConstants.java | 25 ++++
.../vaadin/flow/component/FocusableTest.java | 16 +--
.../java/com/vaadin/flow/dom/ElementTest.java | 42 +++---
.../server/communication/UidlWriterTest.java | 37 ++++++
.../startup/DevModeStartupListener.java | 3 +-
.../startup/DevModeClassFinderTest.java | 3 +-
23 files changed, 778 insertions(+), 192 deletions(-)
create mode 100644 flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokers.java
create mode 100644 flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokersTest.java
delete mode 100644 flow-server/src/main/java/com/vaadin/flow/dom/JsCommand.java
create mode 100644 flow-server/src/main/java/com/vaadin/flow/dom/JsInvoker.java
diff --git a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/BundleValidationUtil.java b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/BundleValidationUtil.java
index 9a922e9cab5..75128f8d151 100644
--- a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/BundleValidationUtil.java
+++ b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/BundleValidationUtil.java
@@ -278,6 +278,16 @@ private static boolean needsBuildInternal(Options options,
((ObjectNode) statsJson.get(FRONTEND_HASHES_STATS_KEY)).remove(
FrontendUtils.GENERATED + FrontendUtils.COMMERCIAL_BANNER_JS);
+ if (jsInvokersChanged(options, statsJson)) {
+ UsageStatistics.markAsUsed(
+ "flow/rebundle-reason-changed-js-invokers", null);
+ return true;
+ }
+ // js invoker file hash has already been checked
+ // removing it from hashes map to prevent other unnecessary checks
+ ((ObjectNode) statsJson.get(FRONTEND_HASHES_STATS_KEY)).remove(
+ FrontendUtils.GENERATED + FrontendUtils.JS_INVOKERS_FILE_NAME);
+
if (!BundleValidationUtil.frontendImportsFound(statsJson, options)) {
UsageStatistics.markAsUsed(
"flow/rebundle-reason-missing-frontend-import", null);
@@ -993,6 +1003,39 @@ private static boolean isCommercialBannerConditionChanged(Options options,
return false;
}
+ /**
+ * Checks whether the JavaScript that the {@code @JsInvoker} interfaces of
+ * the application declare differs from what the bundle was built with.
+ *
+ * The functions are generated into the bundle, so a declaration that
+ * changed, an invoker that was added and a bundle built before invokers
+ * existed all mean that the bundle no longer contains what a call would
+ * look up, which shows up at runtime as a call that cannot be run.
+ */
+ private static boolean jsInvokersChanged(Options options,
+ JsonNode statsJson) {
+ JsonNode frontendHashes = statsJson.get(FRONTEND_HASHES_STATS_KEY);
+ String jsInvokersPath = FrontendUtils.GENERATED
+ + FrontendUtils.JS_INVOKERS_FILE_NAME;
+ String content = new TaskGenerateJsInvokers(options).getFileContent();
+
+ if (!frontendHashes.has(jsInvokersPath)) {
+ getLogger().info(
+ "Detected a bundle that was built without the JavaScript of the invoker interfaces");
+ return true;
+ }
+
+ List faultyContent = new ArrayList<>();
+ compareFrontendHashes(frontendHashes, faultyContent, jsInvokersPath,
+ content);
+ if (!faultyContent.isEmpty()) {
+ getLogger().info(
+ "Detected changed JavaScript declared by the invoker interfaces");
+ return true;
+ }
+ return false;
+ }
+
private static Map getRemainingImports(
List jarImports, List projectImports,
JsonNode frontendHashes) {
diff --git a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/NodeTasks.java b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/NodeTasks.java
index 8bd6909e6d3..f881f932068 100644
--- a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/NodeTasks.java
+++ b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/NodeTasks.java
@@ -76,6 +76,7 @@ public class NodeTasks implements FallibleCommand {
TaskGenerateWebComponentHtml.class,
TaskGenerateWebComponentBootstrap.class,
TaskGenerateFeatureFlags.class,
+ TaskGenerateJsInvokers.class,
TaskInstallFrontendBuildPlugins.class,
TaskUpdatePackages.class,
TaskRunNpmInstall.class,
@@ -262,6 +263,8 @@ public NodeTasks(Options options) {
commands.add(new TaskGenerateFeatureFlags(options));
+ commands.add(new TaskGenerateJsInvokers(options));
+
if (options.getJarFiles() != null
&& options.getJarFrontendResourcesFolder() != null) {
commands.add(new TaskCopyFrontendFiles(options));
diff --git a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateBootstrap.java b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateBootstrap.java
index ed849341f99..21e8158fcb5 100644
--- a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateBootstrap.java
+++ b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateBootstrap.java
@@ -32,6 +32,7 @@
import static com.vaadin.flow.internal.FrontendUtils.INDEX_JS;
import static com.vaadin.flow.internal.FrontendUtils.INDEX_TS;
import static com.vaadin.flow.internal.FrontendUtils.INDEX_TSX;
+import static com.vaadin.flow.internal.FrontendUtils.JS_INVOKERS_FILE_NAME;
/**
* A task for generating the bootstrap file
@@ -83,6 +84,7 @@ protected String getFileContent() {
for (TypeScriptBootstrapModifier modifier : modifiers) {
modifier.modify(lines, options);
}
+ lines.add(0, String.format("import './%s';%n", JS_INVOKERS_FILE_NAME));
lines.add(0,
String.format("import './%s';%n", FEATURE_FLAGS_FILE_NAME));
return String.join(System.lineSeparator(), lines);
diff --git a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokers.java b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokers.java
new file mode 100644
index 00000000000..0cddb5797d5
--- /dev/null
+++ b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokers.java
@@ -0,0 +1,125 @@
+/*
+ * 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.server.frontend;
+
+import java.io.File;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.stream.IntStream;
+
+import com.vaadin.flow.dom.JsExpression;
+import com.vaadin.flow.dom.JsInvoker;
+import com.vaadin.flow.dom.JsInvokerCall;
+import com.vaadin.flow.internal.FrontendUtils;
+
+import static com.vaadin.flow.internal.FrontendUtils.GENERATED;
+import static com.vaadin.flow.internal.FrontendUtils.JS_INVOKERS_FILE_NAME;
+
+/**
+ * Generates {@link FrontendUtils#JS_INVOKERS_FILE_NAME}, which registers the
+ * JavaScript of every {@link JsInvoker} interface on the class path as an
+ * ordinary function of the bundle.
+ *
+ * This is what lets the client run a server-initiated call without compiling
+ * anything from a string: the server sends which interface and method to run,
+ * and the function is already in the bundle, so the call survives a content
+ * security policy that does not allow unsafe-eval and the
+ * JavaScript an application can be made to run is known when it is built.
+ *
+ * For internal use only. May be renamed or removed in a future release.
+ */
+public class TaskGenerateJsInvokers extends AbstractTaskClientGenerator {
+
+ private final Options options;
+
+ TaskGenerateJsInvokers(Options options) {
+ this.options = options;
+ }
+
+ @Override
+ protected String getFileContent() {
+ List lines = new ArrayList<>();
+ lines.add("// @ts-nocheck");
+ lines.add("window.Vaadin = window.Vaadin || {};");
+ lines.add("window.Vaadin.Flow = window.Vaadin.Flow || {};");
+ lines.add(
+ "window.Vaadin.Flow.jsInvokers = window.Vaadin.Flow.jsInvokers || {};");
+
+ options.getClassFinder().getAnnotatedClasses(JsInvoker.class).stream()
+ .sorted(Comparator.comparing(Class::getName))
+ .forEach(invoker -> appendInvoker(lines, invoker));
+
+ // See https://github.com/vaadin/flow/issues/14184
+ lines.add("export {};");
+
+ return String.join(System.lineSeparator(), lines);
+ }
+
+ private static void appendInvoker(List lines, Class> invoker) {
+ List methods = new ArrayList<>();
+ for (Method method : invoker.getMethods()) {
+ if (method.isAnnotationPresent(JsExpression.class)) {
+ methods.add(method);
+ }
+ }
+ if (methods.isEmpty()) {
+ return;
+ }
+ methods.sort(Comparator.comparing(TaskGenerateJsInvokers::methodId));
+
+ lines.add(String.format(
+ "window.Vaadin.Flow.jsInvokers[%s] = Object.assign(window.Vaadin.Flow.jsInvokers[%s] || {}, {",
+ quote(invoker.getName()), quote(invoker.getName())));
+ for (Method method : methods) {
+ // The parameters of the generated function are the arguments of the
+ // call, referenced as $0, $1, ... by the declared expression, and
+ // the element the invoker was obtained from is its `this` - the
+ // same contract as an executeJs expression has.
+ String parameters = IntStream.range(0, method.getParameterCount())
+ .mapToObj(index -> "$" + index)
+ .reduce((first, second) -> first + ", " + second)
+ .orElse("");
+ lines.add(String.format(" %s: async function (%s) {",
+ quote(methodId(method)), parameters));
+ lines.add(method.getAnnotation(JsExpression.class).value());
+ lines.add(" },");
+ }
+ lines.add("});");
+ }
+
+ private static String methodId(Method method) {
+ return JsInvokerCall.methodId(method.getName(),
+ method.getParameterCount());
+ }
+
+ private static String quote(String value) {
+ return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
+ }
+
+ @Override
+ protected File getGeneratedFile() {
+ File frontendGeneratedDirectory = new File(
+ options.getFrontendDirectory(), GENERATED);
+ return new File(frontendGeneratedDirectory, JS_INVOKERS_FILE_NAME);
+ }
+
+ @Override
+ protected boolean shouldGenerate() {
+ return options.getClassFinder() != null;
+ }
+}
diff --git a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateWebComponentBootstrap.java b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateWebComponentBootstrap.java
index 29fe303c917..e651cc0ce6f 100644
--- a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateWebComponentBootstrap.java
+++ b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateWebComponentBootstrap.java
@@ -24,6 +24,7 @@
import static com.vaadin.flow.internal.FrontendUtils.FEATURE_FLAGS_FILE_NAME;
import static com.vaadin.flow.internal.FrontendUtils.GENERATED;
+import static com.vaadin.flow.internal.FrontendUtils.JS_INVOKERS_FILE_NAME;
import static com.vaadin.flow.internal.FrontendUtils.WEB_COMPONENT_BOOTSTRAP_FILE_NAME;
/**
@@ -59,6 +60,7 @@ public class TaskGenerateWebComponentBootstrap
protected String getFileContent() {
List lines = new ArrayList<>();
lines.add(String.format("import './%s';%n", FEATURE_FLAGS_FILE_NAME));
+ lines.add(String.format("import './%s';%n", JS_INVOKERS_FILE_NAME));
lines.add("import 'Frontend/generated/flow/"
+ FrontendUtils.IMPORTS_WEB_COMPONENT_NAME + "';");
// By path rather than through the `vaadin-flow-client` specifier that
diff --git a/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/BundleValidationTest.java b/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/BundleValidationTest.java
index d0fa157551f..cf76af9809c 100644
--- a/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/BundleValidationTest.java
+++ b/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/BundleValidationTest.java
@@ -203,6 +203,11 @@ public void execute() {
frontendHashes.put("theme-util.js",
BundleValidationUtil.calculateHash(THEME_UTIL_JS));
jarResources.put("theme-util.js", THEME_UTIL_JS);
+ // A bundle carries the JavaScript declared by the invoker interfaces
+ frontendHashes.put(
+ FrontendUtils.GENERATED + FrontendUtils.JS_INVOKERS_FILE_NAME,
+ BundleValidationUtil.calculateHash(
+ new TaskGenerateJsInvokers(options).getFileContent()));
return stats;
}
@@ -1067,6 +1072,25 @@ void frontendFileHashMatches_noBundleRebuild(Mode mode) throws IOException {
assertFalse(needsBuild, "Jar fronted file content hash should match.");
}
+ @ParameterizedTest
+ @MethodSource("modes")
+ void jsInvokerJavaScriptChanged_bundleRebuild(Mode mode) {
+ setupMode(mode);
+
+ ObjectNode stats = getBasicStats();
+ ((ObjectNode) stats.get(FRONTEND_HASHES)).put(
+ FrontendUtils.GENERATED + FrontendUtils.JS_INVOKERS_FILE_NAME,
+ BundleValidationUtil
+ .calculateHash("window.Vaadin.Flow.jsInvokers = {};"));
+ setupFrontendUtilsMock(stats);
+
+ boolean needsBuild = BundleValidationUtil.needsBuild(options,
+ depScanner, mode);
+
+ assertTrue(needsBuild,
+ "JavaScript declared by an invoker interface that the bundle was not built with should trigger a rebuild");
+ }
+
@ParameterizedTest
@MethodSource("modes")
void noFrontendFileHash_bundleRebuild(Mode mode) throws IOException {
diff --git a/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokersTest.java b/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokersTest.java
new file mode 100644
index 00000000000..de0e3c94a75
--- /dev/null
+++ b/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokersTest.java
@@ -0,0 +1,95 @@
+/*
+ * 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.server.frontend;
+
+import java.io.File;
+import java.io.Serializable;
+import java.util.Set;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.mockito.Mockito;
+
+import com.vaadin.flow.di.Lookup;
+import com.vaadin.flow.dom.JsExpression;
+import com.vaadin.flow.dom.JsInvoker;
+import com.vaadin.flow.server.frontend.scanner.ClassFinder.DefaultClassFinder;
+
+import static com.vaadin.flow.internal.FrontendUtils.FRONTEND;
+import static com.vaadin.flow.internal.FrontendUtils.GENERATED;
+import static com.vaadin.flow.internal.FrontendUtils.JS_INVOKERS_FILE_NAME;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class TaskGenerateJsInvokersTest {
+
+ @JsInvoker
+ public interface GreeterJs extends Serializable {
+ @JsExpression("window.alert($0)")
+ void showGreeting(String greeting);
+
+ @JsExpression("window.alert('Hello')")
+ void showGreeting();
+ }
+
+ @TempDir
+ File temporaryFolder;
+
+ private TaskGenerateJsInvokers task;
+ private File frontendFolder;
+
+ @BeforeEach
+ void setUp() {
+ frontendFolder = new File(temporaryFolder, FRONTEND);
+ frontendFolder.mkdirs();
+ Options options = new Options(Mockito.mock(Lookup.class),
+ new DefaultClassFinder(Set.of(GreeterJs.class)), null)
+ .withFrontendDirectory(frontendFolder);
+ task = new TaskGenerateJsInvokers(options);
+ }
+
+ @Test
+ void generatesAFunctionPerDeclaredExpression()
+ throws ExecutionFailedException {
+ task.execute();
+ String content = task.getFileContent();
+
+ assertTrue(
+ content.contains("window.Vaadin.Flow.jsInvokers[\""
+ + GreeterJs.class.getName() + "\"]"),
+ "the invoker should be registered under its class name: "
+ + content);
+ assertTrue(
+ content.contains("\"showGreeting/1\": async function ($0) {"),
+ "an overload should be keyed by name and argument count: "
+ + content);
+ assertTrue(content.contains("window.alert($0)"),
+ "the declared expression should be the body of the function: "
+ + content);
+ assertTrue(content.contains("\"showGreeting/0\": async function () {"),
+ "the no-argument overload should be generated too: " + content);
+ }
+
+ @Test
+ void writesTheFileTheBootstrapImports() throws ExecutionFailedException {
+ task.execute();
+
+ assertTrue(
+ new File(new File(frontendFolder, GENERATED),
+ JS_INVOKERS_FILE_NAME).exists(),
+ "the generated file should be where the bootstrap imports it from");
+ }
+}
diff --git a/flow-client/src/main/frontend/internal/client/flow/ExecuteJavaScriptProcessor.ts b/flow-client/src/main/frontend/internal/client/flow/ExecuteJavaScriptProcessor.ts
index f46b8373d09..f4adcdba0d3 100644
--- a/flow-client/src/main/frontend/internal/client/flow/ExecuteJavaScriptProcessor.ts
+++ b/flow-client/src/main/frontend/internal/client/flow/ExecuteJavaScriptProcessor.ts
@@ -66,6 +66,37 @@ interface ContextCallbacks {
disposeInitializer: (node: StateNode, id: number) => void;
}
+/**
+ * What a JS invoker invocation ends with instead of an expression: the invoker
+ * interface and the method to look up in the bundle, how many of the leading
+ * parameters are the arguments of the call, and whether the two parameters
+ * after the element are the channels for the return value.
+ */
+export interface JsInvokerTarget {
+ invoker: string;
+ method: string;
+ arguments: number;
+ returns?: boolean;
+}
+
+type JsInvokerFunction = (this: unknown, ...args: unknown[]) => unknown;
+
+type ReturnChannel = (value: unknown) => void;
+
+/**
+ * Looks up the function that the build generated for an invoker method. The
+ * registry is populated by the generated bundle, so the function is ordinary
+ * bundled code and nothing has to be compiled from a string here.
+ */
+function findInvokerFunction(invoker: string, method: string): JsInvokerFunction | undefined {
+ const registry = (
+ window as unknown as {
+ Vaadin?: { Flow?: { jsInvokers?: Record> } };
+ }
+ ).Vaadin?.Flow?.jsInvokers;
+ return registry?.[invoker]?.[method];
+}
+
/**
* Processes the result of `Page.executeJs` on the client. `Page` is a
* flow-server class, outside this port, so the reference stays a code span.
@@ -124,7 +155,15 @@ export class ExecuteJavaScriptProcessor {
}
}
- parameterNamesAndCode.push(invocation[invocation.length - 1] as string);
+ const target = invocation[invocation.length - 1];
+ if (typeof target === 'object' && target !== null) {
+ // A JS invoker call: the bundle has the function, the server sent only
+ // which one to run.
+ this.invokeFromBundle(target as JsInvokerTarget, parameters);
+ return;
+ }
+
+ parameterNamesAndCode.push(target as string);
this.invoke(parameterNamesAndCode, parameters, nodeParameters);
}
@@ -195,6 +234,50 @@ export class ExecuteJavaScriptProcessor {
});
invokeJavaScript(parameterNamesAndCode, parameters, context, configuration.isProductionMode());
}
+
+ /**
+ * Executes a call made through a JS invoker: looks the function up in the
+ * registry that the generated bundle populates and applies it to the element,
+ * with the arguments of the call. Nothing is compiled from a string, which is
+ * what makes this path work under a content security policy that does not
+ * allow `unsafe-eval`.
+ *
+ * Protected instead of private for testing purposes, as `invoke` is.
+ *
+ * @param target - the invoker interface and method to run
+ * @param parameters - the decoded parameters: the arguments of the call, the
+ * element to apply the function to, and the return value channels
+ * when the target declares them
+ */
+ protected invokeFromBundle(target: JsInvokerTarget, parameters: unknown[]): void {
+ const argumentCount = target.arguments;
+ const onSuccess = target.returns === true ? (parameters[argumentCount + 1] as ReturnChannel) : undefined;
+ const onError = target.returns === true ? (parameters[argumentCount + 2] as ReturnChannel) : undefined;
+
+ const fn = findInvokerFunction(target.invoker, target.method);
+ if (fn === undefined) {
+ const message = `No JavaScript in the bundle for ${target.invoker}.${target.method}. The invoker interface is annotated with @JsInvoker, but the build did not collect it.`;
+ Console.error(message);
+ onError?.(message);
+ return;
+ }
+
+ // The element the invoker was obtained from is the parameter after the
+ // arguments, and it is what the function runs against.
+ const thisArg = parameters.length > argumentCount ? parameters[argumentCount] : undefined;
+ try {
+ const result = fn.apply(thisArg, parameters.slice(0, argumentCount));
+ if (onSuccess !== undefined) {
+ Promise.resolve(result).then(onSuccess, (error: unknown) => onError?.(`${error}`));
+ }
+ } catch (exception) {
+ Console.reportStacktrace(exception);
+ Console.error(
+ `Exception is thrown while running ${target.invoker}.${target.method}. Stacktrace will be dumped separately.`
+ );
+ onError?.(`${exception}`);
+ }
+ }
}
/**
diff --git a/flow-client/src/test/frontend/internal/client/flow/ExecuteJavaScriptProcessorTests.ts b/flow-client/src/test/frontend/internal/client/flow/ExecuteJavaScriptProcessorTests.ts
index c6225785d41..5d570a327b6 100644
--- a/flow-client/src/test/frontend/internal/client/flow/ExecuteJavaScriptProcessorTests.ts
+++ b/flow-client/src/test/frontend/internal/client/flow/ExecuteJavaScriptProcessorTests.ts
@@ -69,6 +69,90 @@ function registeredNode(registry: TestRegistry, id: number): StateNode {
}
describe('ExecuteJavaScriptProcessor', () => {
+ describe('js invoker calls', () => {
+ const INVOKER = 'com.acme.GreeterJs';
+
+ type InvokerFunction = (this: unknown, ...args: unknown[]) => unknown;
+
+ type InvokerWindow = Window & {
+ Vaadin?: { Flow?: { jsInvokers?: Record> } };
+ };
+
+ // Registers a function the way the generated bundle does.
+ function registerInvoker(method: string, fn: InvokerFunction): void {
+ const vaadin = (window as InvokerWindow).Vaadin ?? {};
+ (window as InvokerWindow).Vaadin = vaadin;
+ vaadin.Flow = vaadin.Flow ?? {};
+ vaadin.Flow.jsInvokers = vaadin.Flow.jsInvokers ?? {};
+ vaadin.Flow.jsInvokers[INVOKER] = { ...vaadin.Flow.jsInvokers[INVOKER], [method]: fn };
+ }
+
+ function processor(): ExecuteJavaScriptProcessor {
+ return new ExecuteJavaScriptProcessor(
+ testRegistry({
+ StateTree: { getNode: () => null },
+ ApplicationConfiguration: { getApplicationId: () => 'ROOT-1', isProductionMode: () => false }
+ })
+ );
+ }
+
+ afterEach(() => {
+ delete (window as InvokerWindow).Vaadin?.Flow?.jsInvokers?.[INVOKER];
+ });
+
+ it('runs the function from the bundle against the element', () => {
+ const calls: Array<{ thisArg: unknown; args: unknown[] }> = [];
+ registerInvoker('showGreeting/1', function (this: unknown, ...args: unknown[]) {
+ calls.push({ thisArg: this, args });
+ });
+ const element = { tagName: 'div' };
+
+ processor().execute([['Hello', element, { invoker: INVOKER, method: 'showGreeting/1', arguments: 1 }]]);
+
+ expect(calls).to.have.lengthOf(1);
+ expect(calls[0].thisArg).to.equal(element);
+ expect(calls[0].args).to.eql(['Hello']);
+ });
+
+ it('passes the return value to the success channel', async () => {
+ registerInvoker('readValue/0', () => 'answer');
+ const resolved: unknown[] = [];
+ const element = { tagName: 'div' };
+
+ processor().execute([
+ [
+ element,
+ (value: unknown) => resolved.push(value),
+ () => {},
+ { invoker: INVOKER, method: 'readValue/0', arguments: 0, returns: true }
+ ]
+ ]);
+ // Settled in microtasks: a macrotask wait would also pick up the
+ // asynchronous rethrow that the expression cases leave behind.
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(resolved).to.eql(['answer']);
+ });
+
+ it('reports a function that is not in the bundle to the error channel', () => {
+ const errors: unknown[] = [];
+ const element = { tagName: 'div' };
+
+ processor().execute([
+ [
+ element,
+ () => {},
+ (error: unknown) => errors.push(error),
+ { invoker: INVOKER, method: 'missing/0', arguments: 0, returns: true }
+ ]
+ ]);
+
+ expect(errors).to.have.lengthOf(1);
+ expect(String(errors[0])).to.contain(INVOKER);
+ });
+ });
+
describe('execute', () => {
it('passes the parameters and code of each invocation on', () => {
// Ported from execute_parametersAndCodeAreValidAndNoNodeParameters.
diff --git a/flow-server/src/main/java/com/vaadin/flow/component/FocusJs.java b/flow-server/src/main/java/com/vaadin/flow/component/FocusJs.java
index b1901e76982..e9212cd9526 100644
--- a/flow-server/src/main/java/com/vaadin/flow/component/FocusJs.java
+++ b/flow-server/src/main/java/com/vaadin/flow/component/FocusJs.java
@@ -21,6 +21,7 @@
import com.vaadin.flow.dom.Element;
import com.vaadin.flow.dom.JsExpression;
+import com.vaadin.flow.dom.JsInvoker;
/**
* The client-side operations behind {@link Focusable}, as an invoker interface
@@ -34,6 +35,7 @@
* An invoker interface extends {@link Serializable}, like everything else a
* component can hold on to.
*/
+@JsInvoker
public interface FocusJs extends Serializable {
/**
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 6ad24457ab9..1691a3a7fb3 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,7 +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.JsInvokerCall;
import com.vaadin.flow.dom.impl.BasicElementStateProvider;
import com.vaadin.flow.function.DeploymentConfiguration;
import com.vaadin.flow.internal.ActiveStyleSheetTracker;
@@ -128,7 +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;
+ private final @Nullable JsInvokerCall invokerCall;
/**
* Creates a new invocation.
@@ -140,22 +140,22 @@ public static class JavaScriptInvocation implements Serializable {
* @since 25.0
*/
public JavaScriptInvocation(String expression, Object... parameters) {
- this((JsCommand) null, expression, parameters);
+ this((JsInvokerCall) null, expression, parameters);
}
/**
- * Creates a new invocation for the given command, whose expression and
- * parameters the caller has already resolved.
+ * Creates a new invocation for the given invoker call, whose expression
+ * and parameters the caller has already resolved.
*
- * @param command
- * the command that this invocation performs, or
+ * @param invokerCall
+ * the call 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,
+ public JavaScriptInvocation(@Nullable JsInvokerCall invokerCall,
String expression, Object... parameters) {
/*
* To ensure attached elements are actually attached, the parameters
@@ -170,7 +170,7 @@ public JavaScriptInvocation(@Nullable JsCommand command,
this.expression = expression;
Collections.addAll(this.parameters, parameters);
- this.command = command;
+ this.invokerCall = invokerCall;
}
/**
@@ -192,14 +192,17 @@ public List getParameters() {
}
/**
- * Gets the command that this invocation performs, for a caller that
- * acts on the invocation instead of running its JavaScript.
+ * Gets the invoker call that this invocation performs, for a caller
+ * that acts on the invocation instead of running its JavaScript — the
+ * client, which looks up the generated function rather than compiling
+ * the expression, and a driver of the client side that recognizes the
+ * call.
*
- * @return the command, or null if the invocation is plain
- * JavaScript with no command describing it
+ * @return the call, or null if the invocation is plain
+ * JavaScript scheduled with an expression
*/
- public @Nullable JsCommand getCommand() {
- return command;
+ public @Nullable JsInvokerCall getInvokerCall() {
+ return invokerCall;
}
}
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 75a280040f7..f11a1fe40ec 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
@@ -1927,44 +1927,21 @@ 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());
+ return scheduleExecuteJs(expression, parameters);
}
/**
* Gets an invoker for the JavaScript expressions that the given interface
* declares, bound to this element.
*
- * Each method of the interface is annotated with the {@link JsExpression}
- * it runs. Calling a method schedules that expression the way
- * {@link #executeJs(String, Object...)} would, with the method arguments as
+ * The interface is annotated with {@link JsInvoker} and each of its methods
+ * declares the JavaScript it runs with {@link JsExpression}. Calling a
+ * method runs that JavaScript in the browser with the method arguments as
* its parameters and this element as this:
*
*
- * public interface GreeterJs {
+ * @JsInvoker
+ * public interface GreeterJs extends Serializable {
* @JsExpression("window.alert($0)")
* void showGreeting(String greeting);
* }
@@ -1972,10 +1949,16 @@ public PendingJavaScriptResult executeJs(JsCommand command) {
* element.getJsInvoker(GreeterJs.class).showGreeting("Hello");
*
*
- * The expression is a constant of the interface rather than a string built
- * at the call site, and the scheduled invocation carries the call as a
- * {@link JsInvokerCall} so that a driver of the client side can recognize
- * it, or run it on its own implementation of the same interface.
+ * Unlike {@link #executeJs(String, Object...)}, nothing about the
+ * JavaScript is decided at the call site: the build collects the
+ * declarations of every invoker interface into the bundle, and the client
+ * runs the collected function after looking it up by interface and method.
+ * No expression is sent and none is compiled in the browser, so the call
+ * works under a content security policy without unsafe-eval.
+ *
+ * The scheduled invocation carries the call as a {@link JsInvokerCall}, so
+ * a driver of the client side that can not run JavaScript can recognize it,
+ * or run it on its own implementation of the same interface.
*
* A method returns either void or
* {@link PendingJavaScriptResult}.
@@ -1993,6 +1976,11 @@ public T getJsInvoker(Class invokerType) {
throw new IllegalArgumentException(
invokerType.getName() + " is not an interface");
}
+ if (!invokerType.isAnnotationPresent(JsInvoker.class)) {
+ throw new IllegalArgumentException(invokerType.getName()
+ + " is not annotated with @JsInvoker, so the build does not"
+ + " collect its JavaScript into the bundle");
+ }
return (T) Proxy.newProxyInstance(invokerType.getClassLoader(),
new Class>[] { invokerType },
new JsInvokerHandler(this, invokerType));
@@ -2014,8 +2002,8 @@ public Object invoke(Object proxy, Method method, Object[] args)
List arguments = args == null ? List.of()
: Arrays.asList(args);
PendingJavaScriptResult result = element
- .executeJs(new JsInvokerCall(invokerType, method.getName(),
- arguments));
+ .scheduleInvokerCall(new JsInvokerCall(invokerType,
+ method.getName(), arguments));
if (method.getReturnType() == void.class) {
return null;
}
@@ -2029,26 +2017,37 @@ public Object invoke(Object proxy, Method method, Object[] args)
}
}
- private PendingJavaScriptResult scheduleExecuteJs(
- @Nullable JsCommand command, String expression,
+ private PendingJavaScriptResult scheduleExecuteJs(String expression,
Object[] parameters) {
- // Add "this" as the last parameter
- Object[] wrappedParameters;
- if (parameters.length == 0) {
- wrappedParameters = new Object[] { this };
- } else {
- wrappedParameters = Arrays.copyOf(parameters,
- parameters.length + 1);
- wrappedParameters[parameters.length] = this;
- }
-
// Wrap in a function that is applied with last parameter as "this"
String wrappedExpression = "return (async function() { " + expression
+ "}).apply($" + parameters.length + ")";
- return scheduleJavaScriptInvocation(command, wrappedExpression,
- wrappedParameters);
+ return scheduleJavaScriptInvocation(null, wrappedExpression,
+ withElementAsLastParameter(parameters));
+ }
+
+ /**
+ * Schedules a call made through a JS invoker. The parameters are the
+ * arguments of the call followed by this element, which the client applies
+ * the generated function to, so there is no expression to wrap: the
+ * function that the build generated is already the equivalent of the
+ * wrapping that {@link #scheduleExecuteJs(String, Object[])} does around an
+ * expression.
+ */
+ private PendingJavaScriptResult scheduleInvokerCall(JsInvokerCall call) {
+ return scheduleJavaScriptInvocation(call, call.getExpression(),
+ withElementAsLastParameter(call.arguments().toArray()));
+ }
+
+ private Object[] withElementAsLastParameter(Object[] parameters) {
+ if (parameters.length == 0) {
+ return new Object[] { this };
+ }
+ Object[] withElement = Arrays.copyOf(parameters, parameters.length + 1);
+ withElement[parameters.length] = this;
+ return withElement;
}
/**
@@ -2113,11 +2112,11 @@ public Registration addJsInitializer(String expression,
}
private PendingJavaScriptResult scheduleJavaScriptInvocation(
- @Nullable JsCommand command, String expression,
+ @Nullable JsInvokerCall invokerCall, String expression,
Object[] parameters) {
StateNode node = getNode();
- JavaScriptInvocation invocation = new JavaScriptInvocation(command,
+ JavaScriptInvocation invocation = new JavaScriptInvocation(invokerCall,
expression, parameters);
PendingJavaScriptInvocation pending = new PendingJavaScriptInvocation(
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
deleted file mode 100644
index abf54c4d172..00000000000
--- a/flow-server/src/main/java/com/vaadin/flow/dom/JsCommand.java
+++ /dev/null
@@ -1,82 +0,0 @@
-/*
- * 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/main/java/com/vaadin/flow/dom/JsInvoker.java b/flow-server/src/main/java/com/vaadin/flow/dom/JsInvoker.java
new file mode 100644
index 00000000000..f52c3d30b1e
--- /dev/null
+++ b/flow-server/src/main/java/com/vaadin/flow/dom/JsInvoker.java
@@ -0,0 +1,43 @@
+/*
+ * 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.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Marks an interface whose methods declare the JavaScript they run with
+ * {@link JsExpression}, to be called through
+ * {@link Element#getJsInvoker(Class)}.
+ *
+ * The annotation is what makes the interface findable during the build: every
+ * annotated interface is collected into the generated bundle as a function per
+ * method, so the JavaScript an application can invoke from the server is known
+ * before it runs and the client never has to build a function from a string.
+ * That is what keeps a server-initiated call compatible with a content security
+ * policy that does not allow unsafe-eval.
+ *
+ * @see JsExpression
+ * @see Element#getJsInvoker(Class)
+ */
+@Documented
+@Target(ElementType.TYPE)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface JsInvoker {
+}
diff --git a/flow-server/src/main/java/com/vaadin/flow/dom/JsInvokerCall.java b/flow-server/src/main/java/com/vaadin/flow/dom/JsInvokerCall.java
index edcf5d4ed45..92594e3cad3 100644
--- a/flow-server/src/main/java/com/vaadin/flow/dom/JsInvokerCall.java
+++ b/flow-server/src/main/java/com/vaadin/flow/dom/JsInvokerCall.java
@@ -15,6 +15,7 @@
*/
package com.vaadin.flow.dom;
+import java.io.Serializable;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Arrays;
@@ -25,11 +26,12 @@
* A call made through {@link Element#getJsInvoker(Class)}: which invoker
* interface, which method of it, and the arguments that were passed.
*
- * This is the {@link JsCommand} that the invoker schedules, so the call is what
- * a driver of the client side sees in the pending JavaScript queue. It can
- * dispatch on the interface and the method name, or hand the call to an
- * implementation of the same interface with {@link #invokeOn(Object)} and let
- * Java do the dispatching:
+ * The call is what the client receives — the interface, the method and the
+ * arguments, never the JavaScript itself, which the client looks up in the
+ * bundle. It is also what a driver of the client side that can not run
+ * JavaScript sees in the pending invocation queue. Such a driver can dispatch
+ * on the interface and the method, or hand the call to an implementation of the
+ * same interface with {@link #invokeOn(Object)} and let Java dispatch it:
*
*
* if (call.invokerType() == FocusJs.class) {
@@ -45,7 +47,7 @@
* the arguments of the call, in declaration order
*/
public record JsInvokerCall(Class> invokerType, String methodName,
- List arguments) implements JsCommand {
+ List arguments) implements Serializable {
/**
* Creates a call of the given method of the given invoker interface.
@@ -63,7 +65,51 @@ public record JsInvokerCall(Class> invokerType, String methodName,
arguments = List.copyOf(arguments);
}
- @Override
+ /**
+ * Gets the identifier of the invoker interface, which is the key the
+ * generated bundle registers its functions under.
+ *
+ * @return the invoker identifier, not null
+ */
+ public String getInvokerId() {
+ return invokerType.getName();
+ }
+
+ /**
+ * Gets the identifier of the called method within its invoker, which is the
+ * method name and the number of arguments, so that overloads stay apart.
+ *
+ * @return the method identifier, not null
+ */
+ public String getMethodId() {
+ return methodId(methodName, arguments.size());
+ }
+
+ /**
+ * Gets the identifier of a method with the given name and number of
+ * arguments.
+ *
+ * @param methodName
+ * the method name, not null
+ * @param argumentCount
+ * the number of arguments
+ * @return the method identifier, not null
+ */
+ public static String methodId(String methodName, int argumentCount) {
+ return methodName + "/" + argumentCount;
+ }
+
+ /**
+ * Gets the JavaScript that this call runs in a browser, as declared by
+ * {@link JsExpression} on the called method.
+ *
+ * The expression is not sent to the client, which runs the function that
+ * the build generated from the same declaration. It is available here for
+ * the server side, for instance for a test that asserts what a browser
+ * would run.
+ *
+ * @return the JavaScript expression, not null
+ */
public String getExpression() {
JsExpression annotation = resolveMethod()
.getAnnotation(JsExpression.class);
@@ -75,11 +121,6 @@ public String getExpression() {
return annotation.value();
}
- @Override
- public List getParameters() {
- return arguments;
- }
-
/**
* Runs this call on an implementation of the invoker interface, which is
* how a driver of the client side reproduces it without running the
diff --git a/flow-server/src/main/java/com/vaadin/flow/internal/FrontendUtils.java b/flow-server/src/main/java/com/vaadin/flow/internal/FrontendUtils.java
index 9d53deb65ab..c4f131a1944 100644
--- a/flow-server/src/main/java/com/vaadin/flow/internal/FrontendUtils.java
+++ b/flow-server/src/main/java/com/vaadin/flow/internal/FrontendUtils.java
@@ -214,6 +214,13 @@ public class FrontendUtils {
*/
public static final String FEATURE_FLAGS_FILE_NAME = "vaadin-featureflags.js";
+ /**
+ * File name of the generated file that registers the JavaScript of the
+ * {@code @JsInvoker} interfaces on the class path, so that the client can
+ * run a server-initiated call without compiling an expression.
+ */
+ public static final String JS_INVOKERS_FILE_NAME = "vaadin-js-invokers.js";
+
/**
* File name of the index.html in client side.
*/
diff --git a/flow-server/src/main/java/com/vaadin/flow/server/communication/UidlWriter.java b/flow-server/src/main/java/com/vaadin/flow/server/communication/UidlWriter.java
index b3273cc132e..429867d6401 100644
--- a/flow-server/src/main/java/com/vaadin/flow/server/communication/UidlWriter.java
+++ b/flow-server/src/main/java/com/vaadin/flow/server/communication/UidlWriter.java
@@ -43,6 +43,7 @@
import com.vaadin.flow.component.internal.DependencyList;
import com.vaadin.flow.component.internal.PendingJavaScriptInvocation;
import com.vaadin.flow.component.internal.UIInternals;
+import com.vaadin.flow.dom.JsInvokerCall;
import com.vaadin.flow.function.SerializableConsumer;
import com.vaadin.flow.internal.JacksonCodec;
import com.vaadin.flow.internal.JacksonUtils;
@@ -330,6 +331,11 @@ private static ReturnChannelRegistration createReturnValueChannel(
private static ArrayNode encodeExecuteJavaScript(
PendingJavaScriptInvocation invocation) {
+ JsInvokerCall invokerCall = invocation.getInvocation().getInvokerCall();
+ if (invokerCall != null) {
+ return encodeInvokerCall(invocation, invokerCall);
+ }
+
List parametersList = invocation.getInvocation()
.getParameters();
@@ -378,6 +384,48 @@ private static ArrayNode encodeExecuteJavaScript(
.collect(JacksonUtils.asArray());
}
+ /**
+ * Encodes a call made through a JS invoker as
+ * [argument1, ..., element, successChannel, errorChannel, target],
+ * where the trailing target object names the invoker interface and the
+ * method instead of carrying JavaScript. The client runs the function that
+ * the build generated from the declaration of that method, so no expression
+ * is sent and nothing is compiled in the browser.
+ *
+ * The target tells the client how to read the parameters: the first
+ * arguments of them are the arguments of the call, the next
+ * one is the element to apply the function to, and the two after that are
+ * the return value channels when returns is set.
+ */
+ private static ArrayNode encodeInvokerCall(
+ PendingJavaScriptInvocation invocation, JsInvokerCall call) {
+ Stream parameters = invocation.getInvocation().getParameters()
+ .stream();
+
+ ObjectNode target = JacksonUtils.createObjectNode();
+ target.put(JsonConstants.UIDL_KEY_INVOKER, call.getInvokerId());
+ target.put(JsonConstants.UIDL_KEY_INVOKER_METHOD, call.getMethodId());
+ target.put(JsonConstants.UIDL_KEY_INVOKER_ARGUMENTS,
+ call.arguments().size());
+
+ if (invocation.isSubscribed()) {
+ StateNode owner = invocation.getOwner();
+ List channels = new ArrayList<>();
+
+ ReturnChannelRegistration successChannel = createReturnValueChannel(
+ owner, channels, invocation::complete);
+ ReturnChannelRegistration errorChannel = createReturnValueChannel(
+ owner, channels, invocation::completeExceptionally);
+
+ parameters = Stream.concat(parameters,
+ Stream.of(successChannel, errorChannel));
+ target.put(JsonConstants.UIDL_KEY_INVOKER_RETURNS, true);
+ }
+
+ return Stream.concat(parameters.map(JacksonCodec::encodeWithTypeInfo),
+ Stream.of(target)).collect(JacksonUtils.asArray());
+ }
+
/**
* Encodes the state tree changes of the given UI. The executions registered
* at
diff --git a/flow-server/src/main/java/com/vaadin/flow/shared/JsonConstants.java b/flow-server/src/main/java/com/vaadin/flow/shared/JsonConstants.java
index 599e69317d6..99c0d489fb3 100644
--- a/flow-server/src/main/java/com/vaadin/flow/shared/JsonConstants.java
+++ b/flow-server/src/main/java/com/vaadin/flow/shared/JsonConstants.java
@@ -165,6 +165,31 @@ public class JsonConstants implements Serializable {
*/
public static final String UIDL_KEY_EXECUTE = "execute";
+ /**
+ * Key of the invoker interface in the target object that ends a JS invoker
+ * invocation in UIDL messages, in place of a JavaScript expression.
+ */
+ public static final String UIDL_KEY_INVOKER = "invoker";
+
+ /**
+ * Key of the invoked method, as its name and argument count, in the target
+ * object of a JS invoker invocation.
+ */
+ public static final String UIDL_KEY_INVOKER_METHOD = "method";
+
+ /**
+ * Key of the number of leading parameters that are the arguments of a JS
+ * invoker invocation. The parameter after them is the element to apply the
+ * function to.
+ */
+ public static final String UIDL_KEY_INVOKER_ARGUMENTS = "arguments";
+
+ /**
+ * Key that marks a JS invoker invocation whose two last parameters are the
+ * channels for its return value.
+ */
+ public static final String UIDL_KEY_INVOKER_RETURNS = "returns";
+
/**
* Key used to hold the feature id when synchronizing node values.
*/
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 da83f74d3ce..a7c112656b5 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
@@ -25,7 +25,6 @@
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.flow.dom.JsInvokerCall;
import com.vaadin.tests.util.MockUI;
@@ -277,7 +276,7 @@ void focus_invocationCarriesTheInvokerCallWithTheOptions() {
ui.add(component);
component.focus(PreventScroll.ENABLED);
- JsInvokerCall call = (JsInvokerCall) dumpSingleCommand();
+ JsInvokerCall call = dumpSingleCall();
assertEquals(FocusJs.class, call.invokerType());
assertEquals("focus", call.methodName());
assertEquals("{\"preventScroll\":true}",
@@ -291,7 +290,7 @@ void focusWithoutOptions_invocationCarriesTheNoArgumentCall() {
component.focus();
assertEquals(new JsInvokerCall(FocusJs.class, "focus", List.of()),
- dumpSingleCommand());
+ dumpSingleCall());
}
@Test
@@ -300,7 +299,7 @@ void blur_invocationCarriesTheBlurCall() {
component.blur();
assertEquals(new JsInvokerCall(FocusJs.class, "blur", List.of()),
- dumpSingleCommand());
+ dumpSingleCall());
}
@Test
@@ -317,9 +316,8 @@ void pendingInvocations_runOnAnImplementationOfTheInvoker_plainJavaScriptLeftInt
List unhandledJs = new ArrayList<>();
for (PendingJavaScriptInvocation pending : ui
.dumpPendingJsInvocations()) {
- JsCommand command = pending.getInvocation().getCommand();
- if (command instanceof JsInvokerCall call
- && call.invokerType() == FocusJs.class) {
+ JsInvokerCall call = pending.getInvocation().getInvokerCall();
+ if (call != null && call.invokerType() == FocusJs.class) {
call.invokeOn(new FocusSimulation(
Element.get(pending.getOwner()), log));
} else {
@@ -339,11 +337,11 @@ void pendingInvocations_runOnAnImplementationOfTheInvoker_plainJavaScriptLeftInt
"the unhandled invocation should be the application JavaScript");
}
- private JsCommand dumpSingleCommand() {
+ private JsInvokerCall dumpSingleCall() {
List invocations = ui
.dumpPendingJsInvocations();
assertEquals(1, invocations.size());
- return invocations.get(0).getInvocation().getCommand();
+ return invocations.get(0).getInvocation().getInvokerCall();
}
/**
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 42f4adb76d5..32c80c2762f 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
@@ -2663,38 +2663,40 @@ void callFunctionOnSubProperty() {
}
@Test
- void executeJsWithCommand_sameInvocationAsTheStringForm() {
+ void getJsInvoker_schedulesTheDeclaredExpressionAndCarriesTheCall() {
UI ui = new MockUI();
Element element = ElementFactory.createDiv();
ui.getElement().appendChild(element);
- element.executeJs(new TestCommand("foo"));
- element.executeJs("this.method($0)", "foo");
+ element.getJsInvoker(TestJs.class).method("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();
+ assertEquals(1, pendingJs.size());
+ JavaScriptInvocation invocation = pendingJs.get(0).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");
+ assertEquals("this.method($0)", invocation.getExpression(),
+ "the declared expression should not be wrapped, since the generated function is what runs");
+ assertEquals(List.of("foo", element), invocation.getParameters(),
+ "the arguments should be followed by the element to apply the function to");
+ assertEquals(new JsInvokerCall(TestJs.class, "method", List.of("foo")),
+ invocation.getInvokerCall());
}
- private record TestCommand(String value) implements JsCommand {
- @Override
- public String getExpression() {
- return "this.method($0)";
- }
+ @Test
+ void getJsInvoker_interfaceWithoutAnnotation_throws() {
+ Element element = ElementFactory.createDiv();
- @Override
- public List getParameters() {
- return List.of(value);
- }
+ assertThrows(IllegalArgumentException.class,
+ () -> element.getJsInvoker(Serializable.class),
+ "an interface the build does not collect should be rejected");
+ }
+
+ @JsInvoker
+ interface TestJs extends Serializable {
+ @JsExpression("this.method($0)")
+ void method(String value);
}
@Test
diff --git a/flow-server/src/test/java/com/vaadin/flow/server/communication/UidlWriterTest.java b/flow-server/src/test/java/com/vaadin/flow/server/communication/UidlWriterTest.java
index ae613af7c26..3f1b8b378f6 100644
--- a/flow-server/src/test/java/com/vaadin/flow/server/communication/UidlWriterTest.java
+++ b/flow-server/src/test/java/com/vaadin/flow/server/communication/UidlWriterTest.java
@@ -17,6 +17,7 @@
import jakarta.servlet.http.HttpServletRequest;
+import java.io.Serializable;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@@ -47,6 +48,9 @@
import com.vaadin.flow.di.Lookup;
import com.vaadin.flow.dom.Element;
import com.vaadin.flow.dom.ElementFactory;
+import com.vaadin.flow.dom.JsExpression;
+import com.vaadin.flow.dom.JsInvoker;
+import com.vaadin.flow.dom.JsInvokerCall;
import com.vaadin.flow.internal.BundleUtils;
import com.vaadin.flow.internal.JacksonUtils;
import com.vaadin.flow.internal.StateTree;
@@ -201,6 +205,39 @@ void testEncodeExecuteJavaScript_npmMode() {
assertTrue(JacksonUtils.jsonEquals(expectedJson, json));
}
+ @Test
+ void encodeExecuteJavaScript_invokerCall_sendsTheTargetInsteadOfTheScript() {
+ Element element = ElementFactory.createDiv();
+
+ JsInvokerCall call = new JsInvokerCall(TestJs.class, "method",
+ List.of("foo"));
+ JavaScriptInvocation invocation = new JavaScriptInvocation(call,
+ call.getExpression(), "foo", element);
+
+ ArrayNode json = UidlWriter.encodeExecuteJavaScriptList(
+ List.of(new PendingJavaScriptInvocation(element.getNode(),
+ invocation)));
+
+ ObjectNode target = JacksonUtils.createObjectNode();
+ target.put("invoker", TestJs.class.getName());
+ target.put("method", "method/1");
+ target.put("arguments", 1);
+ ArrayNode expectedJson = JacksonUtils.createArray(
+ JacksonUtils.createArray(JacksonUtils.createNode("foo"),
+ // Null since element is not attached
+ JacksonUtils.nullNode(), target));
+
+ assertTrue(JacksonUtils.jsonEquals(expectedJson, json),
+ "an invoker call should carry its target, and no JavaScript: "
+ + json);
+ }
+
+ @JsInvoker
+ interface TestJs extends Serializable {
+ @JsExpression("this.method($0)")
+ void method(String value);
+ }
+
@Test
void componentDependencies_npmMode() throws Exception {
UI ui = initializeUIForDependenciesTest(new TestUI());
diff --git a/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/startup/DevModeStartupListener.java b/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/startup/DevModeStartupListener.java
index 93db16c3b47..ebecdfb9d0e 100644
--- a/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/startup/DevModeStartupListener.java
+++ b/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/startup/DevModeStartupListener.java
@@ -38,6 +38,7 @@
import com.vaadin.flow.component.dependency.StyleSheet;
import com.vaadin.flow.component.page.AppShellConfigurator;
import com.vaadin.flow.di.Lookup;
+import com.vaadin.flow.dom.JsInvoker;
import com.vaadin.flow.internal.DevModeHandlerManager;
import com.vaadin.flow.internal.Template;
import com.vaadin.flow.router.HasErrorParameter;
@@ -73,7 +74,7 @@
Template.class, LoadDependenciesOnStartup.class,
TypeScriptBootstrapModifier.class, DevToolsMessageHandler.class,
Component.class, Layout.class, StyleSheet.class,
- StyleSheet.Container.class })
+ StyleSheet.Container.class, JsInvoker.class })
@WebListener
public class DevModeStartupListener
implements VaadinServletContextStartupInitializer, Serializable,
diff --git a/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/startup/DevModeClassFinderTest.java b/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/startup/DevModeClassFinderTest.java
index f0d3cb8b61b..3305fa6849f 100644
--- a/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/startup/DevModeClassFinderTest.java
+++ b/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/startup/DevModeClassFinderTest.java
@@ -37,6 +37,7 @@
import com.vaadin.flow.component.dependency.NpmPackage;
import com.vaadin.flow.component.dependency.StyleSheet;
import com.vaadin.flow.component.page.AppShellConfigurator;
+import com.vaadin.flow.dom.JsInvoker;
import com.vaadin.flow.internal.Template;
import com.vaadin.flow.router.HasErrorParameter;
import com.vaadin.flow.router.Layout;
@@ -75,7 +76,7 @@ void applicableClasses_knownClasses() {
Template.class, LoadDependenciesOnStartup.class,
Component.class, TypeScriptBootstrapModifier.class,
DevToolsMessageHandler.class, Layout.class, StyleSheet.class,
- StyleSheet.Container.class);
+ StyleSheet.Container.class, JsInvoker.class);
for (Class> clz : classes) {
assertTrue(knownClasses.contains(clz),
From 4521d7acd7adc36875fbcd2c08385eca4fa3d221 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 07:32:48 +0000
Subject: [PATCH 04/57] test: cover the failure paths of the JS invoker API
The error cases of getJsInvoker and JsInvokerCall had no tests, which is
what the coverage gate on the pull request flagged. The return type is
now also checked before the call is scheduled, so a method the invoker
can not answer does not reach the browser, and what an implementation
throws in invokeOn reaches the caller instead of being wrapped.
---
.../server/frontend/BundleValidationTest.java | 17 +++
.../frontend/TaskGenerateJsInvokersTest.java | 22 ++-
.../java/com/vaadin/flow/dom/Element.java | 21 +--
.../com/vaadin/flow/dom/JsInvokerCall.java | 13 +-
.../java/com/vaadin/flow/dom/ElementTest.java | 55 ++++++++
.../vaadin/flow/dom/JsInvokerCallTest.java | 126 ++++++++++++++++++
.../server/communication/UidlWriterTest.java | 26 ++++
7 files changed, 267 insertions(+), 13 deletions(-)
create mode 100644 flow-server/src/test/java/com/vaadin/flow/dom/JsInvokerCallTest.java
diff --git a/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/BundleValidationTest.java b/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/BundleValidationTest.java
index cf76af9809c..656824d1418 100644
--- a/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/BundleValidationTest.java
+++ b/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/BundleValidationTest.java
@@ -1072,6 +1072,23 @@ void frontendFileHashMatches_noBundleRebuild(Mode mode) throws IOException {
assertFalse(needsBuild, "Jar fronted file content hash should match.");
}
+ @ParameterizedTest
+ @MethodSource("modes")
+ void bundleWithoutJsInvokerJavaScript_bundleRebuild(Mode mode) {
+ setupMode(mode);
+
+ ObjectNode stats = getBasicStats();
+ ((ObjectNode) stats.get(FRONTEND_HASHES)).remove(
+ FrontendUtils.GENERATED + FrontendUtils.JS_INVOKERS_FILE_NAME);
+ setupFrontendUtilsMock(stats);
+
+ boolean needsBuild = BundleValidationUtil.needsBuild(options,
+ depScanner, mode);
+
+ assertTrue(needsBuild,
+ "a bundle built before the invoker JavaScript existed should be rebuilt");
+ }
+
@ParameterizedTest
@MethodSource("modes")
void jsInvokerJavaScriptChanged_bundleRebuild(Mode mode) {
diff --git a/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokersTest.java b/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokersTest.java
index de0e3c94a75..c912d3b5ffe 100644
--- a/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokersTest.java
+++ b/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokersTest.java
@@ -32,6 +32,7 @@
import static com.vaadin.flow.internal.FrontendUtils.FRONTEND;
import static com.vaadin.flow.internal.FrontendUtils.GENERATED;
import static com.vaadin.flow.internal.FrontendUtils.JS_INVOKERS_FILE_NAME;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class TaskGenerateJsInvokersTest {
@@ -45,6 +46,11 @@ public interface GreeterJs extends Serializable {
void showGreeting();
}
+ @JsInvoker
+ public interface NothingJs extends Serializable {
+ void notDeclared();
+ }
+
@TempDir
File temporaryFolder;
@@ -56,8 +62,9 @@ void setUp() {
frontendFolder = new File(temporaryFolder, FRONTEND);
frontendFolder.mkdirs();
Options options = new Options(Mockito.mock(Lookup.class),
- new DefaultClassFinder(Set.of(GreeterJs.class)), null)
- .withFrontendDirectory(frontendFolder);
+ new DefaultClassFinder(
+ Set.of(GreeterJs.class, NothingJs.class)),
+ null).withFrontendDirectory(frontendFolder);
task = new TaskGenerateJsInvokers(options);
}
@@ -83,6 +90,17 @@ void generatesAFunctionPerDeclaredExpression()
"the no-argument overload should be generated too: " + content);
}
+ @Test
+ void invokerWithoutDeclaredJavaScript_isNotRegistered()
+ throws ExecutionFailedException {
+ task.execute();
+ String content = task.getFileContent();
+
+ assertFalse(content.contains(NothingJs.class.getName()),
+ "an interface that declares no JavaScript has nothing to register: "
+ + content);
+ }
+
@Test
void writesTheFileTheBootstrapImports() throws ExecutionFailedException {
task.execute();
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 f11a1fe40ec..54a5bd49cee 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
@@ -1999,21 +1999,22 @@ public Object invoke(Object proxy, Method method, Object[] args)
if (method.getDeclaringClass() == Object.class) {
return method.invoke(this, args);
}
+ Class> returnType = method.getReturnType();
+ boolean returnsResult = returnType
+ .isAssignableFrom(PendingJavaScriptResult.class);
+ // Checked before scheduling, so that a method the invoker can not
+ // answer does not run in the browser either
+ if (returnType != void.class && !returnsResult) {
+ throw new IllegalStateException("Method " + method.getName()
+ + " of " + invokerType.getName()
+ + " must return void or PendingJavaScriptResult");
+ }
List arguments = args == null ? List.of()
: Arrays.asList(args);
PendingJavaScriptResult result = element
.scheduleInvokerCall(new JsInvokerCall(invokerType,
method.getName(), arguments));
- if (method.getReturnType() == void.class) {
- return null;
- }
- if (method.getReturnType()
- .isAssignableFrom(PendingJavaScriptResult.class)) {
- return result;
- }
- throw new IllegalStateException("Method " + method.getName()
- + " of " + invokerType.getName()
- + " must return void or PendingJavaScriptResult");
+ return returnsResult ? result : null;
}
}
diff --git a/flow-server/src/main/java/com/vaadin/flow/dom/JsInvokerCall.java b/flow-server/src/main/java/com/vaadin/flow/dom/JsInvokerCall.java
index 92594e3cad3..7ec91b6916e 100644
--- a/flow-server/src/main/java/com/vaadin/flow/dom/JsInvokerCall.java
+++ b/flow-server/src/main/java/com/vaadin/flow/dom/JsInvokerCall.java
@@ -131,6 +131,9 @@ public String getExpression() {
* null
* @return the value returned by the implementation, or null
* for a void method
+ * @throws IllegalArgumentException
+ * if the implementation does not implement
+ * {@link #invokerType()}
*/
public Object invokeOn(Object implementation) {
if (!invokerType.isInstance(implementation)) {
@@ -141,8 +144,16 @@ public Object invokeOn(Object implementation) {
try {
return resolveMethod().invoke(implementation, arguments.toArray());
} catch (IllegalAccessException | InvocationTargetException e) {
+ Throwable cause = e instanceof InvocationTargetException
+ ? e.getCause()
+ : e;
+ // What the implementation threw is what the caller wants to see
+ if (cause instanceof RuntimeException runtimeException) {
+ throw runtimeException;
+ }
throw new IllegalStateException(
- "Could not run " + methodName + " on " + implementation, e);
+ "Could not run " + methodName + " on " + implementation,
+ cause);
}
}
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 32c80c2762f..20d42cabec4 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
@@ -2693,6 +2693,61 @@ void getJsInvoker_interfaceWithoutAnnotation_throws() {
"an interface the build does not collect should be rejected");
}
+ @Test
+ void getJsInvoker_notAnInterface_throws() {
+ Element element = ElementFactory.createDiv();
+
+ assertThrows(IllegalArgumentException.class,
+ () -> element.getJsInvoker(ElementTest.class),
+ "only an interface can declare invoker methods");
+ }
+
+ @Test
+ void getJsInvoker_methodReturningAResult_schedulesAndReturnsIt() {
+ UI ui = new MockUI();
+ Element element = ElementFactory.createDiv();
+ ui.getElement().appendChild(element);
+
+ ResultJs invoker = element.getJsInvoker(ResultJs.class);
+ assertNotNull(invoker.toString(),
+ "the invoker should answer the methods of Object");
+
+ PendingJavaScriptResult result = invoker.readValue();
+ ui.getInternals().getStateTree().runExecutionsBeforeClientResponse();
+
+ assertNotNull(result,
+ "a method declaring a result should return the pending result");
+ assertEquals(1,
+ ui.getInternals().dumpPendingJavaScriptInvocations().size());
+ }
+
+ @Test
+ void getJsInvoker_methodWithAnotherReturnType_throwsAndSchedulesNothing() {
+ UI ui = new MockUI();
+ Element element = ElementFactory.createDiv();
+ ui.getElement().appendChild(element);
+
+ assertThrows(IllegalStateException.class,
+ () -> element.getJsInvoker(UnsupportedJs.class).readValue());
+ ui.getInternals().getStateTree().runExecutionsBeforeClientResponse();
+
+ assertTrue(
+ ui.getInternals().dumpPendingJavaScriptInvocations().isEmpty(),
+ "a method the invoker can not answer should not run in the browser either");
+ }
+
+ @JsInvoker
+ interface ResultJs extends Serializable {
+ @JsExpression("return this.value;")
+ PendingJavaScriptResult readValue();
+ }
+
+ @JsInvoker
+ interface UnsupportedJs extends Serializable {
+ @JsExpression("return this.value;")
+ String readValue();
+ }
+
@JsInvoker
interface TestJs extends Serializable {
@JsExpression("this.method($0)")
diff --git a/flow-server/src/test/java/com/vaadin/flow/dom/JsInvokerCallTest.java b/flow-server/src/test/java/com/vaadin/flow/dom/JsInvokerCallTest.java
new file mode 100644
index 00000000000..da9bdb67df2
--- /dev/null
+++ b/flow-server/src/test/java/com/vaadin/flow/dom/JsInvokerCallTest.java
@@ -0,0 +1,126 @@
+/*
+ * 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;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class JsInvokerCallTest {
+
+ @JsInvoker
+ interface GreeterJs extends Serializable {
+ @JsExpression("window.alert($0)")
+ void showGreeting(String greeting);
+
+ void undeclared();
+
+ @JsExpression("this.ambiguous($0)")
+ void ambiguous(String value);
+
+ @JsExpression("this.ambiguous($0)")
+ void ambiguous(int value);
+ }
+
+ private static class Greeter implements GreeterJs {
+ private final List greetings = new java.util.ArrayList<>();
+
+ @Override
+ public void showGreeting(String greeting) {
+ greetings.add(greeting);
+ }
+
+ @Override
+ public void undeclared() {
+ throw new UnsupportedOperationException("nothing to do here");
+ }
+
+ @Override
+ public void ambiguous(String value) {
+ }
+
+ @Override
+ public void ambiguous(int value) {
+ }
+ }
+
+ private static JsInvokerCall call(String methodName, Object... arguments) {
+ return new JsInvokerCall(GreeterJs.class, methodName,
+ List.of(arguments));
+ }
+
+ @Test
+ void identifiers_nameTheInterfaceAndTheMethodWithItsArity() {
+ JsInvokerCall call = call("showGreeting", "Hello");
+
+ assertEquals(GreeterJs.class.getName(), call.getInvokerId());
+ assertEquals("showGreeting/1", call.getMethodId());
+ }
+
+ @Test
+ void getExpression_methodWithoutDeclaredJavaScript_throws() {
+ IllegalStateException exception = assertThrows(
+ IllegalStateException.class,
+ () -> call("undeclared").getExpression());
+
+ assertTrue(exception.getMessage().contains("@JsExpression"),
+ "the message should name what the method is missing: "
+ + exception.getMessage());
+ }
+
+ @Test
+ void getExpression_overloadsWithTheSameArity_throws() {
+ // Overloads are resolved by name and argument count, so two of them
+ // with the same arity can not be told apart
+ assertThrows(IllegalStateException.class,
+ () -> call("ambiguous", "value").getExpression());
+ }
+
+ @Test
+ void invokeOn_implementation_runsTheMethod() {
+ Greeter greeter = new Greeter();
+
+ call("showGreeting", "Hello").invokeOn(greeter);
+
+ assertEquals(List.of("Hello"), greeter.greetings);
+ }
+
+ @Test
+ void invokeOn_somethingElse_throws() {
+ IllegalArgumentException exception = assertThrows(
+ IllegalArgumentException.class,
+ () -> call("showGreeting", "Hello").invokeOn("not a greeter"));
+
+ assertTrue(exception.getMessage().contains(GreeterJs.class.getName()),
+ "the message should name the interface that was expected: "
+ + exception.getMessage());
+ }
+
+ @Test
+ void invokeOn_implementationThrows_theFailureReachesTheCaller() {
+ UnsupportedOperationException exception = assertThrows(
+ UnsupportedOperationException.class,
+ () -> call("undeclared").invokeOn(new Greeter()));
+
+ assertEquals("nothing to do here", exception.getMessage(),
+ "what the implementation threw should not be wrapped");
+ }
+}
diff --git a/flow-server/src/test/java/com/vaadin/flow/server/communication/UidlWriterTest.java b/flow-server/src/test/java/com/vaadin/flow/server/communication/UidlWriterTest.java
index 3f1b8b378f6..e7a2a98d30a 100644
--- a/flow-server/src/test/java/com/vaadin/flow/server/communication/UidlWriterTest.java
+++ b/flow-server/src/test/java/com/vaadin/flow/server/communication/UidlWriterTest.java
@@ -232,6 +232,32 @@ void encodeExecuteJavaScript_invokerCall_sendsTheTargetInsteadOfTheScript() {
+ json);
}
+ @Test
+ void encodeExecuteJavaScript_subscribedInvokerCall_addsTheReturnChannels() {
+ Element element = ElementFactory.createDiv();
+
+ JsInvokerCall call = new JsInvokerCall(TestJs.class, "method",
+ List.of("foo"));
+ JavaScriptInvocation invocation = new JavaScriptInvocation(call,
+ call.getExpression(), "foo", element);
+ PendingJavaScriptInvocation pending = new PendingJavaScriptInvocation(
+ element.getNode(), invocation);
+ pending.then(value -> {
+ });
+
+ ArrayNode json = UidlWriter
+ .encodeExecuteJavaScriptList(List.of(pending));
+
+ ArrayNode encoded = (ArrayNode) json.get(0);
+ assertEquals(5, encoded.size(),
+ "the argument and the element should be followed by the two channels and the target: "
+ + encoded);
+ ObjectNode target = (ObjectNode) encoded.get(4);
+ assertTrue(target.get("returns").asBoolean(),
+ "the target should tell the client that the call is subscribed to");
+ assertEquals(1, target.get("arguments").asInt());
+ }
+
@JsInvoker
interface TestJs extends Serializable {
@JsExpression("this.method($0)")
From 88536b8979739dcc2998104b1c75581e11418101 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 08:04:58 +0000
Subject: [PATCH 05/57] fix: keep a precompiled bundle usable when invoker
JavaScript is missing
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The bundle check compared a hash the bundle never carried, since the
Vite stats only hash a known set of files and the generated invoker file
was not one of them. Every application therefore looked out of date and
rebuilt its bundle, which an application that runs on a precompiled
bundle can not do at all — the no-plugin tests caught it.
The stats now hash the generated file the way they hash the commercial
banner, so a bundle carries what its invokers declared, and a bundle
built before invoker interfaces existed is left alone instead of forcing
a rebuild that would not help.
---
.../flow/server/frontend/BundleValidationUtil.java | 11 +++++++++--
.../flow/server/frontend/BundleValidationTest.java | 6 +++---
flow-server/src/main/resources/vite.generated.ts | 12 ++++++++++++
3 files changed, 24 insertions(+), 5 deletions(-)
diff --git a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/BundleValidationUtil.java b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/BundleValidationUtil.java
index 75128f8d151..2a616c6a035 100644
--- a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/BundleValidationUtil.java
+++ b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/BundleValidationUtil.java
@@ -1020,9 +1020,16 @@ private static boolean jsInvokersChanged(Options options,
String content = new TaskGenerateJsInvokers(options).getFileContent();
if (!frontendHashes.has(jsInvokersPath)) {
+ // A bundle built before invoker interfaces existed carries none of
+ // their JavaScript. It is not rebuilt for that: an application
+ // that runs on a precompiled bundle has deliberately no frontend
+ // build, and one that does build its frontend generates the file
+ // as part of the build. What it means is that a call made through
+ // an invoker finds nothing to run until the bundle is built again,
+ // which the client reports per call, so say it once here as well.
getLogger().info(
- "Detected a bundle that was built without the JavaScript of the invoker interfaces");
- return true;
+ "The bundle in use was built without the JavaScript declared by @JsInvoker interfaces. Calls made through an invoker will not run until the frontend is built again.");
+ return false;
}
List faultyContent = new ArrayList<>();
diff --git a/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/BundleValidationTest.java b/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/BundleValidationTest.java
index 656824d1418..c31ab1b1e7d 100644
--- a/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/BundleValidationTest.java
+++ b/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/BundleValidationTest.java
@@ -1074,7 +1074,7 @@ void frontendFileHashMatches_noBundleRebuild(Mode mode) throws IOException {
@ParameterizedTest
@MethodSource("modes")
- void bundleWithoutJsInvokerJavaScript_bundleRebuild(Mode mode) {
+ void bundleWithoutJsInvokerJavaScript_noBundleRebuild(Mode mode) {
setupMode(mode);
ObjectNode stats = getBasicStats();
@@ -1085,8 +1085,8 @@ void bundleWithoutJsInvokerJavaScript_bundleRebuild(Mode mode) {
boolean needsBuild = BundleValidationUtil.needsBuild(options,
depScanner, mode);
- assertTrue(needsBuild,
- "a bundle built before the invoker JavaScript existed should be rebuilt");
+ assertFalse(needsBuild,
+ "a bundle that predates invoker interfaces should keep being used, since an application running on a precompiled bundle has no frontend build to replace it with");
}
@ParameterizedTest
diff --git a/flow-server/src/main/resources/vite.generated.ts b/flow-server/src/main/resources/vite.generated.ts
index 9cd5db25978..751e46f4d74 100644
--- a/flow-server/src/main/resources/vite.generated.ts
+++ b/flow-server/src/main/resources/vite.generated.ts
@@ -137,6 +137,12 @@ const themeOptions = {
const hasExportedWebComponents = existsSync(path.resolve(frontendFolder, 'web-component.html'));
const commercialBannerComponent = path.resolve(frontendFolder, settings.generatedFolder, 'commercial-banner.js');
const hasCommercialBanner = existsSync(commercialBannerComponent);
+// The JavaScript declared by the @JsInvoker interfaces, generated before the
+// build. Hashed into the stats like the banner above, so that a bundle whose
+// invokers changed is rebuilt instead of running with the functions it was
+// built with.
+const jsInvokersFile = path.resolve(frontendFolder, settings.generatedFolder, 'vaadin-js-invokers.js');
+const hasJsInvokers = existsSync(jsInvokersFile);
const target = ['es2023'];
@@ -322,6 +328,12 @@ function statsExtracterPlugin(): PluginOption {
const fileBuffer = readFileSync(commercialBannerComponent, { encoding: 'utf-8' }).replace(/\r\n/g, '\n');
frontendFiles[settings.generatedFolder + '/commercial-banner.js'] = createHash('sha256').update(fileBuffer, 'utf8').digest('hex');
}
+ if (hasJsInvokers) {
+ const fileBuffer = readFileSync(jsInvokersFile, { encoding: 'utf-8' }).replace(/\r\n/g, '\n');
+ frontendFiles[settings.generatedFolder + '/vaadin-js-invokers.js'] = createHash('sha256')
+ .update(fileBuffer, 'utf8')
+ .digest('hex');
+ }
const themeJsonContents: Record = {};
const themesFolder = path.resolve(jarResourcesFolder, 'themes');
From ef99514d4057aedfb22822a57efdbeeeaa498f3d Mon Sep 17 00:00:00 2001
From: "totally-not-ai[bot]"
<290682512+totally-not-ai[bot]@users.noreply.github.com>
Date: Fri, 18 Sep 2026 06:35:24 +0000
Subject: [PATCH 06/57] fix: reject an invoker call whose parameters do not add
up
The client located the element to apply the function to by index, trusting
the argument count that came with the invocation. An invocation built
against another signature would have bound an argument as `this` and run
the call with everything shifted. The count is now compared with what the
target declares and the mismatch is reported instead of running.
What an invoker interface declares is also a frontend change now: the
JavaScript is generated into the bundle, so the dev loop escalates to a
restart for it, the same way it does for a JsModule. The declared
expression is part of the comparison, since editing one keeps the method
it belongs to and would otherwise go unnoticed.
---
.../client/flow/ExecuteJavaScriptProcessor.ts | 17 ++++++++++-
.../flow/ExecuteJavaScriptProcessorTests.ts | 13 ++++++++
.../devserver/devloop/DevLoopRedefiner.java | 21 +++++++++++++
.../devloop/DevLoopRedefinerTest.java | 30 +++++++++++++++++++
4 files changed, 80 insertions(+), 1 deletion(-)
diff --git a/flow-client/src/main/frontend/internal/client/flow/ExecuteJavaScriptProcessor.ts b/flow-client/src/main/frontend/internal/client/flow/ExecuteJavaScriptProcessor.ts
index f4adcdba0d3..82f6dbed6fe 100644
--- a/flow-client/src/main/frontend/internal/client/flow/ExecuteJavaScriptProcessor.ts
+++ b/flow-client/src/main/frontend/internal/client/flow/ExecuteJavaScriptProcessor.ts
@@ -251,6 +251,21 @@ export class ExecuteJavaScriptProcessor {
*/
protected invokeFromBundle(target: JsInvokerTarget, parameters: unknown[]): void {
const argumentCount = target.arguments;
+
+ // The parameters are the arguments of the call, then the element to apply
+ // the function to, then the two return value channels when the target
+ // declares them. Nothing else may be in there, so a count that does not
+ // add up means the invocation was not built by the server this client
+ // talks to, and reading the element out of it by index would bind an
+ // argument as `this`. Say so instead of running the call.
+ const expectedCount = argumentCount + 1 + (target.returns === true ? 2 : 0);
+ if (parameters.length !== expectedCount) {
+ Console.error(
+ `Expected ${expectedCount} parameters for ${target.invoker}.${target.method} but the invocation carries ${parameters.length}. Reload the page to pick up the current signature.`
+ );
+ return;
+ }
+
const onSuccess = target.returns === true ? (parameters[argumentCount + 1] as ReturnChannel) : undefined;
const onError = target.returns === true ? (parameters[argumentCount + 2] as ReturnChannel) : undefined;
@@ -264,7 +279,7 @@ export class ExecuteJavaScriptProcessor {
// The element the invoker was obtained from is the parameter after the
// arguments, and it is what the function runs against.
- const thisArg = parameters.length > argumentCount ? parameters[argumentCount] : undefined;
+ const thisArg = parameters[argumentCount];
try {
const result = fn.apply(thisArg, parameters.slice(0, argumentCount));
if (onSuccess !== undefined) {
diff --git a/flow-client/src/test/frontend/internal/client/flow/ExecuteJavaScriptProcessorTests.ts b/flow-client/src/test/frontend/internal/client/flow/ExecuteJavaScriptProcessorTests.ts
index 5d570a327b6..b5dbcc8ca88 100644
--- a/flow-client/src/test/frontend/internal/client/flow/ExecuteJavaScriptProcessorTests.ts
+++ b/flow-client/src/test/frontend/internal/client/flow/ExecuteJavaScriptProcessorTests.ts
@@ -135,6 +135,19 @@ describe('ExecuteJavaScriptProcessor', () => {
expect(resolved).to.eql(['answer']);
});
+ it('does not run a call whose parameters do not match the target', () => {
+ let calls = 0;
+ registerInvoker('showGreeting/1', () => {
+ calls += 1;
+ });
+
+ // One argument declared, but no element to apply the function to: the
+ // invocation and this client disagree about the signature.
+ processor().execute([['Hello', { invoker: INVOKER, method: 'showGreeting/1', arguments: 1 }]]);
+
+ expect(calls).to.equal(0);
+ });
+
it('reports a function that is not in the bundle to the error channel', () => {
const errors: unknown[] = [];
const element = { tagName: 'div' };
diff --git a/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/devloop/DevLoopRedefiner.java b/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/devloop/DevLoopRedefiner.java
index e8a6c555bcd..9fd1d82cb8a 100644
--- a/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/devloop/DevLoopRedefiner.java
+++ b/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/devloop/DevLoopRedefiner.java
@@ -57,6 +57,9 @@
import com.vaadin.flow.component.dependency.JavaScript;
import com.vaadin.flow.component.dependency.JsModule;
import com.vaadin.flow.component.dependency.NpmPackage;
+import com.vaadin.flow.dom.JsExpression;
+import com.vaadin.flow.dom.JsInvoker;
+import com.vaadin.flow.dom.JsInvokerCall;
import com.vaadin.flow.internal.AnnotationReader;
import com.vaadin.flow.internal.BrowserLiveReload;
import com.vaadin.flow.internal.BrowserLiveReloadAccessor;
@@ -1374,6 +1377,24 @@ static String frontendDependencies(Class> type) {
+ ":" + annotation.themeFor());
}
}
+ // The JavaScript an invoker interface declares is generated into the
+ // bundle by the build, exactly like the imports above, so an edited
+ // expression or a method added or removed only reaches the browser
+ // through a restart that regenerates the file and rebuilds the bundle.
+ // The expression is part of the fingerprint, since a changed one keeps
+ // the same method and would otherwise go unnoticed.
+ if (type.isAnnotationPresent(JsInvoker.class)) {
+ for (Method method : type.getMethods()) {
+ JsExpression expression = method
+ .getAnnotation(JsExpression.class);
+ if (expression != null) {
+ imports.add("jsinvoker:"
+ + JsInvokerCall.methodId(method.getName(),
+ method.getParameterCount())
+ + ":" + expression.value());
+ }
+ }
+ }
// These two are read off the class whatever it is. @Theme in particular
// has to sit on the AppShellConfigurator, which is never a Component -
// so reading it only from Components meant a theme could be added,
diff --git a/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/devloop/DevLoopRedefinerTest.java b/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/devloop/DevLoopRedefinerTest.java
index 6be8aead3a7..0bbb6eb16b3 100644
--- a/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/devloop/DevLoopRedefinerTest.java
+++ b/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/devloop/DevLoopRedefinerTest.java
@@ -18,6 +18,7 @@
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
+import java.io.Serializable;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
@@ -36,10 +37,13 @@
import com.vaadin.flow.component.dependency.JsModule;
import com.vaadin.flow.component.dependency.NpmPackage;
import com.vaadin.flow.component.page.AppShellConfigurator;
+import com.vaadin.flow.dom.JsExpression;
+import com.vaadin.flow.dom.JsInvoker;
import com.vaadin.flow.theme.Theme;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -159,6 +163,18 @@ static class SomeView extends Component {
static class NothingDeclared {
}
+ @JsInvoker
+ interface GreeterJs extends Serializable {
+ @JsExpression("window.alert($0)")
+ void showGreeting(String greeting);
+ }
+
+ @JsInvoker
+ interface EditedGreeterJs extends Serializable {
+ @JsExpression("window.alert('edited ' + $0)")
+ void showGreeting(String greeting);
+ }
+
@Test
void frontendDependencies_seesTheThemeOnAnAppShellThatIsNoComponent() {
// @Theme belongs on the AppShellConfigurator, which is never a
@@ -175,6 +191,20 @@ void frontendDependencies_seesTheThemeOnAnAppShellThatIsNoComponent() {
assertTrue(imports.contains("dark"), imports);
}
+ @Test
+ void frontendDependencies_seesTheJavaScriptAnInvokerDeclares() {
+ // The declared JavaScript is generated into the bundle by the build, so
+ // a redefined interface leaves the browser running the JavaScript the
+ // bundle was built with until a restart regenerates it.
+ String imports = DevLoopRedefiner.frontendDependencies(GreeterJs.class);
+
+ assertTrue(imports.contains("jsinvoker:showGreeting/1"), imports);
+ // An edited expression keeps the same method, so the expression itself
+ // has to be part of the comparison.
+ assertNotEquals(imports,
+ DevLoopRedefiner.frontendDependencies(EditedGreeterJs.class));
+ }
+
@Test
void frontendDependencies_seesBuildTimeImportsOnANonComponent() {
String imports = DevLoopRedefiner
From dc9a9a8b0cd1a4dc20769357395832701f6c85e4 Mon Sep 17 00:00:00 2001
From: "totally-not-ai[bot]"
<290682512+totally-not-ai[bot]@users.noreply.github.com>
Date: Fri, 18 Sep 2026 06:39:01 +0000
Subject: [PATCH 07/57] fix: complete the pending result of an invoker call
that can not run
A call the client refused to run because its parameters did not add up
left the two return channels untouched, so a call that was subscribed to
never completed on the server and the application's handler never ran,
with a line in the browser console as the only trace. The channels are
appended after everything else, or not at all, so the last parameter is
the error channel even when the count in front of it is wrong, and the
message now goes through it.
---
.../client/flow/ExecuteJavaScriptProcessor.ts | 15 ++++++--
.../flow/ExecuteJavaScriptProcessorTests.ts | 37 +++++++++++++++++++
2 files changed, 49 insertions(+), 3 deletions(-)
diff --git a/flow-client/src/main/frontend/internal/client/flow/ExecuteJavaScriptProcessor.ts b/flow-client/src/main/frontend/internal/client/flow/ExecuteJavaScriptProcessor.ts
index 82f6dbed6fe..4def3338a4c 100644
--- a/flow-client/src/main/frontend/internal/client/flow/ExecuteJavaScriptProcessor.ts
+++ b/flow-client/src/main/frontend/internal/client/flow/ExecuteJavaScriptProcessor.ts
@@ -260,9 +260,18 @@ export class ExecuteJavaScriptProcessor {
// argument as `this`. Say so instead of running the call.
const expectedCount = argumentCount + 1 + (target.returns === true ? 2 : 0);
if (parameters.length !== expectedCount) {
- Console.error(
- `Expected ${expectedCount} parameters for ${target.invoker}.${target.method} but the invocation carries ${parameters.length}. Reload the page to pick up the current signature.`
- );
+ const message = `Expected ${expectedCount} parameters for ${target.invoker}.${target.method} but the invocation carries ${parameters.length}. Reload the page to pick up the current signature.`;
+ Console.error(message);
+ // The server appends the two channels after everything else, or neither
+ // of them, so the error channel is the last parameter even when the
+ // count in front of it does not add up. Report through it, or the
+ // pending result of the call is never completed on the server.
+ if (target.returns === true) {
+ const lastParameter = parameters[parameters.length - 1];
+ if (typeof lastParameter === 'function') {
+ (lastParameter as ReturnChannel)(message);
+ }
+ }
return;
}
diff --git a/flow-client/src/test/frontend/internal/client/flow/ExecuteJavaScriptProcessorTests.ts b/flow-client/src/test/frontend/internal/client/flow/ExecuteJavaScriptProcessorTests.ts
index b5dbcc8ca88..9b52e16f903 100644
--- a/flow-client/src/test/frontend/internal/client/flow/ExecuteJavaScriptProcessorTests.ts
+++ b/flow-client/src/test/frontend/internal/client/flow/ExecuteJavaScriptProcessorTests.ts
@@ -148,6 +148,43 @@ describe('ExecuteJavaScriptProcessor', () => {
expect(calls).to.equal(0);
});
+ it('reports a mismatch to the error channel of a call that returns a value', () => {
+ let calls = 0;
+ registerInvoker('readValue/0', () => {
+ calls += 1;
+ return 'answer';
+ });
+ const errors: unknown[] = [];
+ const element = { tagName: 'div' };
+
+ // Subscribed to, but one channel short of what the target declares.
+ processor().execute([
+ [
+ element,
+ (error: unknown) => errors.push(error),
+ { invoker: INVOKER, method: 'readValue/0', arguments: 0, returns: true }
+ ]
+ ]);
+
+ expect(calls).to.equal(0);
+ // Reported rather than left hanging: the pending result on the server
+ // would otherwise never complete.
+ expect(errors).to.have.lengthOf(1);
+ });
+
+ it('does not run a call that carries more parameters than the target declares', () => {
+ let calls = 0;
+ registerInvoker('showGreeting/1', () => {
+ calls += 1;
+ });
+
+ processor().execute([
+ ['Hello', 'unexpected', { tagName: 'div' }, { invoker: INVOKER, method: 'showGreeting/1', arguments: 1 }]
+ ]);
+
+ expect(calls).to.equal(0);
+ });
+
it('reports a function that is not in the bundle to the error channel', () => {
const errors: unknown[] = [];
const element = { tagName: 'div' };
From b9a408cf0f04b58f73c49f0ef00439fa675232d4 Mon Sep 17 00:00:00 2001
From: "totally-not-ai[bot]"
<290682512+totally-not-ai[bot]@users.noreply.github.com>
Date: Fri, 18 Sep 2026 06:58:56 +0000
Subject: [PATCH 08/57] feat: report an invoker whose JavaScript the bundle no
longer carries
A class redefined straight from an IDE never reaches the dev loop, so the
escalation to a restart that a changed invoker interface needs did not
happen there and the browser kept running the JavaScript the bundle was
built with, silently.
A hotswapper now compares what a redefined @JsInvoker interface declares
with the generated file the bundle was built from, which is what the
browser can actually run, and reports the ones it does not carry. There
is nothing to apply in the browser instead: only a frontend build
produces the new function.
---
.../hotswap/impl/JsInvokerHotswapper.java | 140 ++++++++++++++++++
...in.base.devserver.hotswap.VaadinHotswapper | 1 +
.../hotswap/impl/JsInvokerHotswapperTest.java | 70 +++++++++
3 files changed, 211 insertions(+)
create mode 100644 vaadin-dev-server/src/main/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapper.java
create mode 100644 vaadin-dev-server/src/test/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapperTest.java
diff --git a/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapper.java b/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapper.java
new file mode 100644
index 00000000000..13a3e27d763
--- /dev/null
+++ b/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapper.java
@@ -0,0 +1,140 @@
+/*
+ * 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.base.devserver.hotswap.impl;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.Serializable;
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.vaadin.base.devserver.hotswap.HotswapClassEvent;
+import com.vaadin.base.devserver.hotswap.VaadinHotswapper;
+import com.vaadin.flow.dom.JsExpression;
+import com.vaadin.flow.dom.JsInvoker;
+import com.vaadin.flow.dom.JsInvokerCall;
+import com.vaadin.flow.internal.FrontendUtils;
+import com.vaadin.flow.server.VaadinService;
+import com.vaadin.flow.server.startup.ApplicationConfiguration;
+
+/**
+ * Reports a {@link JsInvoker} interface whose JavaScript the frontend bundle no
+ * longer carries.
+ *
+ * The JavaScript an invoker method declares with {@link JsExpression} is
+ * collected into the bundle when the frontend is built. Redefining the
+ * interface therefore does not change what the browser can run: a call made
+ * after the change either runs the JavaScript the bundle was built with, or
+ * finds no function at all when the redefinition added or renamed a method. The
+ * dev loop escalates to a restart for this, but a class redefined straight from
+ * an IDE reaches the application without it, and there is nothing this
+ * hotswapper could apply in the browser instead - only a frontend build
+ * produces the new function. So it says what happened, and what to do about it.
+ *
+ * What the browser can run is what the generated file holds, so that file is
+ * what the declarations are compared against, and an interface whose JavaScript
+ * is already in there passes silently.
+ *
+ * For internal use only. May be renamed or removed in a future release.
+ */
+public class JsInvokerHotswapper implements VaadinHotswapper, Serializable {
+
+ private static final Logger LOGGER = LoggerFactory
+ .getLogger(JsInvokerHotswapper.class);
+
+ @Override
+ public void onClassesChange(HotswapClassEvent event) {
+ List> invokers = event.getChangedClasses().stream()
+ .filter(type -> type.isAnnotationPresent(JsInvoker.class))
+ .toList();
+ if (invokers.isEmpty()) {
+ return;
+ }
+
+ String generated = readGeneratedInvokers(event.getVaadinService());
+ List stale = new ArrayList<>();
+ for (Class> invoker : invokers) {
+ if (!isInBundle(invoker, generated)) {
+ stale.add(invoker.getName());
+ }
+ }
+ if (stale.isEmpty()) {
+ return;
+ }
+
+ LOGGER.warn(
+ "The JavaScript declared by {} is not the JavaScript the frontend bundle carries. "
+ + "It is collected into the bundle when the frontend is built, so calls made through the invoker keep running the previous version, or fail to find a function at all, until the application is restarted.",
+ String.join(", ", stale));
+ }
+
+ /**
+ * Whether everything the invoker declares can be found in the generated
+ * file. A method that was removed is not reported: the function stays in
+ * the bundle with nothing calling it.
+ */
+ // Package-private so the comparison can be asserted directly.
+ static boolean isInBundle(Class> invoker, String generated) {
+ if (generated == null) {
+ // Nothing to compare against, so anything declared may be missing
+ return false;
+ }
+ for (Method method : invoker.getMethods()) {
+ JsExpression expression = method.getAnnotation(JsExpression.class);
+ if (expression == null) {
+ continue;
+ }
+ String methodId = JsInvokerCall.methodId(method.getName(),
+ method.getParameterCount());
+ if (!generated.contains("\"" + methodId + "\"")
+ || !generated.contains(expression.value())) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private static String readGeneratedInvokers(VaadinService service) {
+ ApplicationConfiguration configuration = ApplicationConfiguration
+ .get(service.getContext());
+ if (configuration == null) {
+ return null;
+ }
+ File frontendFolder = FrontendUtils
+ .getProjectFrontendDir(configuration);
+ if (frontendFolder == null) {
+ return null;
+ }
+ File generated = new File(
+ new File(frontendFolder, FrontendUtils.GENERATED),
+ FrontendUtils.JS_INVOKERS_FILE_NAME);
+ if (!generated.exists()) {
+ return null;
+ }
+ try {
+ return Files.readString(generated.toPath(), StandardCharsets.UTF_8);
+ } catch (IOException e) {
+ LOGGER.debug("Could not read {}", generated, e);
+ return null;
+ }
+ }
+}
diff --git a/vaadin-dev-server/src/main/resources/META-INF/services/com.vaadin.base.devserver.hotswap.VaadinHotswapper b/vaadin-dev-server/src/main/resources/META-INF/services/com.vaadin.base.devserver.hotswap.VaadinHotswapper
index b259e50e312..ef80b5b08bf 100644
--- a/vaadin-dev-server/src/main/resources/META-INF/services/com.vaadin.base.devserver.hotswap.VaadinHotswapper
+++ b/vaadin-dev-server/src/main/resources/META-INF/services/com.vaadin.base.devserver.hotswap.VaadinHotswapper
@@ -20,3 +20,4 @@ com.vaadin.base.devserver.hotswap.impl.DefaultTranslationsHotswapper
com.vaadin.base.devserver.hotswap.impl.RouteRegistryHotswapper
com.vaadin.base.devserver.hotswap.impl.ErrorViewHotswapper
com.vaadin.base.devserver.devloop.DevLoopHotswapper
+com.vaadin.base.devserver.hotswap.impl.JsInvokerHotswapper
diff --git a/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapperTest.java b/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapperTest.java
new file mode 100644
index 00000000000..9dbf93898c5
--- /dev/null
+++ b/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapperTest.java
@@ -0,0 +1,70 @@
+/*
+ * 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.base.devserver.hotswap.impl;
+
+import java.io.Serializable;
+
+import org.junit.jupiter.api.Test;
+
+import com.vaadin.flow.dom.JsExpression;
+import com.vaadin.flow.dom.JsInvoker;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class JsInvokerHotswapperTest {
+
+ @JsInvoker
+ interface GreeterJs extends Serializable {
+ @JsExpression("window.alert($0)")
+ void showGreeting(String greeting);
+ }
+
+ private static final String GENERATED_FOR_GREETER = """
+ window.Vaadin.Flow.jsInvokers["com.acme.GreeterJs"] = Object.assign({}, {
+ "showGreeting/1": async function ($0) {
+ window.alert($0)
+ },
+ });
+ """;
+
+ @Test
+ void isInBundle_declarationsAreInTheGeneratedFile() {
+ assertTrue(JsInvokerHotswapper.isInBundle(GreeterJs.class,
+ GENERATED_FOR_GREETER));
+ }
+
+ @Test
+ void isInBundle_generatedFromAnotherVersionOfTheDeclarations() {
+ // The method is still there, but with the JavaScript of before the
+ // change: a call would run that, not what the interface now declares.
+ assertFalse(
+ JsInvokerHotswapper.isInBundle(GreeterJs.class,
+ GENERATED_FOR_GREETER.replace("window.alert($0)",
+ "window.alert('edited ' + $0)")),
+ "an expression the bundle does not carry should be reported");
+ // And the method missing altogether is the same answer.
+ assertFalse(JsInvokerHotswapper.isInBundle(GreeterJs.class,
+ GENERATED_FOR_GREETER.replace("showGreeting/1",
+ "showGreeting/2")));
+ }
+
+ @Test
+ void isInBundle_noGeneratedFile() {
+ assertFalse(JsInvokerHotswapper.isInBundle(GreeterJs.class, null),
+ "without a generated file there is nothing carrying the declarations");
+ }
+}
From 7be1ffdadec76e3473807eefed92e79b6366a6a8 Mon Sep 17 00:00:00 2001
From: "totally-not-ai[bot]"
<290682512+totally-not-ai[bot]@users.noreply.github.com>
Date: Fri, 18 Sep 2026 07:07:30 +0000
Subject: [PATCH 09/57] fix: compare an invoker with the bundle as the build
renders it
Looking for the method id and the expression anywhere in the generated
file passed things it should have reported: an interface renamed or moved
keeps its methods and JavaScript in the bundle under the name of before,
and an expression shortened to a prefix of what the bundle carries still
matched. The comparison now uses the rendering the build wrote, so the
interface name, the methods, their argument counts and the JavaScript all
have to match.
The tests drive the hotswap event instead of the comparison, which also
covers resolving the generated file and ignoring a class that declares
nothing.
---
.../frontend/TaskGenerateJsInvokers.java | 22 ++-
.../hotswap/impl/JsInvokerHotswapper.java | 83 +++++-----
.../hotswap/impl/JsInvokerHotswapperTest.java | 149 ++++++++++++++----
3 files changed, 184 insertions(+), 70 deletions(-)
diff --git a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokers.java b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokers.java
index 0cddb5797d5..fa5a50f9ea1 100644
--- a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokers.java
+++ b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokers.java
@@ -62,7 +62,7 @@ protected String getFileContent() {
options.getClassFinder().getAnnotatedClasses(JsInvoker.class).stream()
.sorted(Comparator.comparing(Class::getName))
- .forEach(invoker -> appendInvoker(lines, invoker));
+ .forEach(invoker -> lines.addAll(invokerLines(invoker)));
// See https://github.com/vaadin/flow/issues/14184
lines.add("export {};");
@@ -70,7 +70,22 @@ protected String getFileContent() {
return String.join(System.lineSeparator(), lines);
}
- private static void appendInvoker(List lines, Class> invoker) {
+ /**
+ * Renders what one invoker interface contributes to the generated file: the
+ * registration of its interface name, and one function per method that
+ * declares JavaScript, keyed by method name and argument count.
+ *
+ * Exposed so that a caller which has to tell whether a bundle carries what
+ * an interface declares - the hotswap path, which compares the two - reads
+ * the same rendering the build wrote, instead of matching parts of it.
+ *
+ * @param invoker
+ * the invoker interface to render, not null
+ * @return the lines this invoker contributes, empty if it declares no
+ * JavaScript
+ */
+ public static List invokerLines(Class> invoker) {
+ List lines = new ArrayList<>();
List methods = new ArrayList<>();
for (Method method : invoker.getMethods()) {
if (method.isAnnotationPresent(JsExpression.class)) {
@@ -78,7 +93,7 @@ private static void appendInvoker(List lines, Class> invoker) {
}
}
if (methods.isEmpty()) {
- return;
+ return lines;
}
methods.sort(Comparator.comparing(TaskGenerateJsInvokers::methodId));
@@ -100,6 +115,7 @@ private static void appendInvoker(List lines, Class> invoker) {
lines.add(" },");
}
lines.add("});");
+ return lines;
}
private static String methodId(Method method) {
diff --git a/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapper.java b/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapper.java
index 13a3e27d763..a8210f02074 100644
--- a/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapper.java
+++ b/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapper.java
@@ -17,8 +17,6 @@
import java.io.File;
import java.io.IOException;
-import java.io.Serializable;
-import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
@@ -31,35 +29,33 @@
import com.vaadin.base.devserver.hotswap.VaadinHotswapper;
import com.vaadin.flow.dom.JsExpression;
import com.vaadin.flow.dom.JsInvoker;
-import com.vaadin.flow.dom.JsInvokerCall;
import com.vaadin.flow.internal.FrontendUtils;
import com.vaadin.flow.server.VaadinService;
+import com.vaadin.flow.server.frontend.TaskGenerateJsInvokers;
import com.vaadin.flow.server.startup.ApplicationConfiguration;
/**
- * Reports a {@link JsInvoker} interface whose JavaScript the frontend bundle no
- * longer carries.
+ * Reports a {@link JsInvoker} interface whose JavaScript the frontend bundle
+ * does not carry.
*
* The JavaScript an invoker method declares with {@link JsExpression} is
* collected into the bundle when the frontend is built. Redefining the
* interface therefore does not change what the browser can run: a call made
* after the change either runs the JavaScript the bundle was built with, or
- * finds no function at all when the redefinition added or renamed a method. The
+ * finds no function at all when the redefinition renamed or added a method. The
* dev loop escalates to a restart for this, but a class redefined straight from
- * an IDE reaches the application without it, and there is nothing this
- * hotswapper could apply in the browser instead - only a frontend build
+ * an IDE reaches the application without going through it, and there is nothing
+ * a hotswapper could apply in the browser instead - only a frontend build
* produces the new function. So it says what happened, and what to do about it.
*
- * What the browser can run is what the generated file holds, so that file is
- * what the declarations are compared against, and an interface whose JavaScript
- * is already in there passes silently.
+ * The comparison is against the generated file the bundle was built from, which
+ * is what the browser can run, and it uses the same rendering the build wrote,
+ * so the interface name, the method names, their argument counts and the
+ * declared JavaScript all have to match for an interface to pass silently.
*
* For internal use only. May be renamed or removed in a future release.
*/
-public class JsInvokerHotswapper implements VaadinHotswapper, Serializable {
-
- private static final Logger LOGGER = LoggerFactory
- .getLogger(JsInvokerHotswapper.class);
+public class JsInvokerHotswapper implements VaadinHotswapper {
@Override
public void onClassesChange(HotswapClassEvent event) {
@@ -77,40 +73,43 @@ public void onClassesChange(HotswapClassEvent event) {
stale.add(invoker.getName());
}
}
- if (stale.isEmpty()) {
- return;
+ if (!stale.isEmpty()) {
+ report(stale);
}
+ }
- LOGGER.warn(
+ /**
+ * Says that the bundle does not carry what the given interfaces declare.
+ *
+ * Package-private so that what a change is reported for can be asserted.
+ *
+ * @param invokerNames
+ * the names of the invoker interfaces to report, never empty
+ */
+ void report(List invokerNames) {
+ getLogger().warn(
"The JavaScript declared by {} is not the JavaScript the frontend bundle carries. "
- + "It is collected into the bundle when the frontend is built, so calls made through the invoker keep running the previous version, or fail to find a function at all, until the application is restarted.",
- String.join(", ", stale));
+ + "It is collected into the bundle when the frontend is built, so a call made through the invoker keeps running the previous version, or finds no function at all, until the application is restarted.",
+ String.join(", ", invokerNames));
}
/**
- * Whether everything the invoker declares can be found in the generated
- * file. A method that was removed is not reported: the function stays in
- * the bundle with nothing calling it.
+ * Whether the generated file carries what the invoker declares, compared as
+ * the build renders it. A method that was removed does not show up as a
+ * difference: its function stays in the bundle with nothing calling it.
*/
- // Package-private so the comparison can be asserted directly.
- static boolean isInBundle(Class> invoker, String generated) {
+ private static boolean isInBundle(Class> invoker, String generated) {
if (generated == null) {
- // Nothing to compare against, so anything declared may be missing
+ // Nothing carries the declarations, so nothing matches them
return false;
}
- for (Method method : invoker.getMethods()) {
- JsExpression expression = method.getAnnotation(JsExpression.class);
- if (expression == null) {
- continue;
- }
- String methodId = JsInvokerCall.methodId(method.getName(),
- method.getParameterCount());
- if (!generated.contains("\"" + methodId + "\"")
- || !generated.contains(expression.value())) {
- return false;
- }
+ List declared = TaskGenerateJsInvokers.invokerLines(invoker);
+ if (declared.isEmpty()) {
+ // Declares no JavaScript, so there is nothing to carry
+ return true;
}
- return true;
+ return generated
+ .contains(String.join(System.lineSeparator(), declared));
}
private static String readGeneratedInvokers(VaadinService service) {
@@ -125,7 +124,7 @@ private static String readGeneratedInvokers(VaadinService service) {
return null;
}
File generated = new File(
- new File(frontendFolder, FrontendUtils.GENERATED),
+ FrontendUtils.getFrontendGeneratedFolder(frontendFolder),
FrontendUtils.JS_INVOKERS_FILE_NAME);
if (!generated.exists()) {
return null;
@@ -133,8 +132,12 @@ private static String readGeneratedInvokers(VaadinService service) {
try {
return Files.readString(generated.toPath(), StandardCharsets.UTF_8);
} catch (IOException e) {
- LOGGER.debug("Could not read {}", generated, e);
+ getLogger().debug("Could not read {}", generated, e);
return null;
}
}
+
+ private static Logger getLogger() {
+ return LoggerFactory.getLogger(JsInvokerHotswapper.class);
+ }
}
diff --git a/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapperTest.java b/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapperTest.java
index 9dbf93898c5..3a6b81c3785 100644
--- a/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapperTest.java
+++ b/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapperTest.java
@@ -15,56 +15,151 @@
*/
package com.vaadin.base.devserver.hotswap.impl;
+import java.io.File;
+import java.io.IOException;
import java.io.Serializable;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.mockito.Mockito;
+import com.vaadin.base.devserver.hotswap.HotswapClassEvent;
import com.vaadin.flow.dom.JsExpression;
import com.vaadin.flow.dom.JsInvoker;
+import com.vaadin.flow.internal.FrontendUtils;
+import com.vaadin.flow.server.MockVaadinServletService;
+import com.vaadin.flow.server.frontend.TaskGenerateJsInvokers;
+import com.vaadin.flow.server.startup.ApplicationConfiguration;
+import com.vaadin.flow.server.startup.ApplicationConfigurationFactory;
+import com.vaadin.tests.util.MockDeploymentConfiguration;
-import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class JsInvokerHotswapperTest {
@JsInvoker
interface GreeterJs extends Serializable {
- @JsExpression("window.alert($0)")
+ @JsExpression("window.alert($0); this.focus()")
void showGreeting(String greeting);
}
- private static final String GENERATED_FOR_GREETER = """
- window.Vaadin.Flow.jsInvokers["com.acme.GreeterJs"] = Object.assign({}, {
- "showGreeting/1": async function ($0) {
- window.alert($0)
- },
- });
- """;
+ static class NotAnInvoker {
+ }
+
+ // Records what a change is reported for instead of logging it.
+ private static class TestHotswapper extends JsInvokerHotswapper {
+ private final List reported = new ArrayList<>();
+
+ @Override
+ void report(List invokerNames) {
+ reported.addAll(invokerNames);
+ }
+ }
+
+ @TempDir
+ File projectFolder;
+
+ private TestHotswapper hotswapper;
+ private MockVaadinServletService service;
+ private File frontendFolder;
+
+ @BeforeEach
+ void setUp() {
+ hotswapper = new TestHotswapper();
+ frontendFolder = new File(projectFolder, FrontendUtils.FRONTEND);
+
+ service = new MockVaadinServletService(
+ new MockDeploymentConfiguration());
+ ApplicationConfiguration configuration = Mockito
+ .mock(ApplicationConfiguration.class);
+ Mockito.when(configuration.getFrontendFolder())
+ .thenReturn(frontendFolder);
+ Mockito.when(service.getLookup()
+ .lookup(ApplicationConfigurationFactory.class))
+ .thenReturn(context -> configuration);
+ }
+
+ private void writeGeneratedInvokers(String content) throws IOException {
+ File generated = FrontendUtils
+ .getFrontendGeneratedFolder(frontendFolder);
+ generated.mkdirs();
+ Files.writeString(
+ new File(generated, FrontendUtils.JS_INVOKERS_FILE_NAME)
+ .toPath(),
+ content, StandardCharsets.UTF_8);
+ }
+
+ private String generatedFor(Class> invoker) {
+ return String.join(System.lineSeparator(),
+ TaskGenerateJsInvokers.invokerLines(invoker));
+ }
+
+ private void classesChanged(Class>... classes) {
+ hotswapper.onClassesChange(
+ new HotswapClassEvent(service, Set.of(classes), true));
+ }
@Test
- void isInBundle_declarationsAreInTheGeneratedFile() {
- assertTrue(JsInvokerHotswapper.isInBundle(GreeterJs.class,
- GENERATED_FOR_GREETER));
+ void bundleCarriesTheDeclarations_nothingReported() throws IOException {
+ writeGeneratedInvokers(generatedFor(GreeterJs.class));
+
+ classesChanged(GreeterJs.class);
+
+ assertTrue(hotswapper.reported.isEmpty(),
+ "a bundle built from these declarations runs exactly them: "
+ + hotswapper.reported);
}
@Test
- void isInBundle_generatedFromAnotherVersionOfTheDeclarations() {
- // The method is still there, but with the JavaScript of before the
- // change: a call would run that, not what the interface now declares.
- assertFalse(
- JsInvokerHotswapper.isInBundle(GreeterJs.class,
- GENERATED_FOR_GREETER.replace("window.alert($0)",
- "window.alert('edited ' + $0)")),
- "an expression the bundle does not carry should be reported");
- // And the method missing altogether is the same answer.
- assertFalse(JsInvokerHotswapper.isInBundle(GreeterJs.class,
- GENERATED_FOR_GREETER.replace("showGreeting/1",
- "showGreeting/2")));
+ void bundleCarriesAnotherVersionOfTheDeclarations_reported()
+ throws IOException {
+ // The JavaScript the interface declared before it was shortened: the
+ // bundle would keep running the extra statement.
+ writeGeneratedInvokers(generatedFor(GreeterJs.class).replace(
+ "window.alert($0); this.focus()",
+ "window.alert($0); this.focus(); this.scrollTo(0, 0)"));
+
+ classesChanged(GreeterJs.class);
+
+ assertEquals(List.of(GreeterJs.class.getName()), hotswapper.reported);
}
@Test
- void isInBundle_noGeneratedFile() {
- assertFalse(JsInvokerHotswapper.isInBundle(GreeterJs.class, null),
- "without a generated file there is nothing carrying the declarations");
+ void bundleCarriesTheDeclarationsUnderAnotherName_reported()
+ throws IOException {
+ // What renaming or moving the interface leaves behind: the methods and
+ // the JavaScript are in the bundle, but under the name of before, so a
+ // call looks up an invoker the bundle does not have.
+ writeGeneratedInvokers(generatedFor(GreeterJs.class)
+ .replace(GreeterJs.class.getName(), "com.example.RenamedJs"));
+
+ classesChanged(GreeterJs.class);
+
+ assertEquals(List.of(GreeterJs.class.getName()), hotswapper.reported);
+ }
+
+ @Test
+ void noGeneratedFile_reported() {
+ classesChanged(GreeterJs.class);
+
+ assertEquals(List.of(GreeterJs.class.getName()), hotswapper.reported,
+ "without a generated file nothing carries the declarations");
+ }
+
+ @Test
+ void noInvokerChanged_nothingReported() throws IOException {
+ writeGeneratedInvokers(generatedFor(GreeterJs.class));
+
+ classesChanged(NotAnInvoker.class);
+
+ assertTrue(hotswapper.reported.isEmpty(),
+ "a class that declares no JavaScript is not a frontend change");
}
}
From 599df466d48dbe47a3df57fa2a867fb7ddc830f8 Mon Sep 17 00:00:00 2001
From: "totally-not-ai[bot]"
<290682512+totally-not-ai[bot]@users.noreply.github.com>
Date: Fri, 18 Sep 2026 08:05:53 +0000
Subject: [PATCH 10/57] feat: apply a changed invoker declaration without a
restart
With the frontend dev server running there is no reason to ask for a
restart: the file the functions are generated into is now written again
from what the interfaces declare, and the file accepts its own update, so
the dev server replaces that module in every browser that has it and a
call made afterwards runs the new JavaScript. Nothing is compiled from a
string in the browser, since what the dev server serves is the file it
just read, and the page is not reloaded.
Without the dev server a bundle is what the browser runs, and only a
build produces a new one, so the change is still reported there.
---
.../frontend/TaskGenerateJsInvokers.java | 31 ++++++-
.../hotswap/impl/JsInvokerHotswapper.java | 92 ++++++++++++++++---
.../hotswap/impl/JsInvokerHotswapperTest.java | 63 ++++++++++++-
3 files changed, 168 insertions(+), 18 deletions(-)
diff --git a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokers.java b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokers.java
index fa5a50f9ea1..91ca50b4c05 100644
--- a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokers.java
+++ b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokers.java
@@ -18,6 +18,7 @@
import java.io.File;
import java.lang.reflect.Method;
import java.util.ArrayList;
+import java.util.Collection;
import java.util.Comparator;
import java.util.List;
import java.util.stream.IntStream;
@@ -53,6 +54,23 @@ public class TaskGenerateJsInvokers extends AbstractTaskClientGenerator {
@Override
protected String getFileContent() {
+ return fileContent(
+ options.getClassFinder().getAnnotatedClasses(JsInvoker.class));
+ }
+
+ /**
+ * Renders the file that registers the JavaScript of the given invoker
+ * interfaces.
+ *
+ * Exposed so that a caller which regenerates the file outside a build - the
+ * hotswap path, which writes it again when an interface changed while the
+ * application runs - produces exactly what a build would have written.
+ *
+ * @param invokers
+ * the invoker interfaces to render, not null
+ * @return the content of the generated file
+ */
+ public static String fileContent(Collection> invokers) {
List lines = new ArrayList<>();
lines.add("// @ts-nocheck");
lines.add("window.Vaadin = window.Vaadin || {};");
@@ -60,10 +78,19 @@ protected String getFileContent() {
lines.add(
"window.Vaadin.Flow.jsInvokers = window.Vaadin.Flow.jsInvokers || {};");
- options.getClassFinder().getAnnotatedClasses(JsInvoker.class).stream()
- .sorted(Comparator.comparing(Class::getName))
+ invokers.stream().sorted(Comparator.comparing(Class::getName))
.forEach(invoker -> lines.addAll(invokerLines(invoker)));
+ // Writing this file again while the application runs replaces it in the
+ // browser that has it: everything above only writes into the registry,
+ // so the module can accept its own update and nothing else has to be
+ // reloaded for a changed declaration to take effect. The dev server
+ // drops the block from a production build, where import.meta.hot is
+ // not defined.
+ lines.add("if (import.meta.hot) {");
+ lines.add(" import.meta.hot.accept();");
+ lines.add("}");
+
// See https://github.com/vaadin/flow/issues/14184
lines.add("export {};");
diff --git a/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapper.java b/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapper.java
index a8210f02074..5550f5a3c2f 100644
--- a/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapper.java
+++ b/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapper.java
@@ -27,11 +27,14 @@
import com.vaadin.base.devserver.hotswap.HotswapClassEvent;
import com.vaadin.base.devserver.hotswap.VaadinHotswapper;
+import com.vaadin.flow.di.Lookup;
import com.vaadin.flow.dom.JsExpression;
import com.vaadin.flow.dom.JsInvoker;
import com.vaadin.flow.internal.FrontendUtils;
+import com.vaadin.flow.server.Mode;
import com.vaadin.flow.server.VaadinService;
import com.vaadin.flow.server.frontend.TaskGenerateJsInvokers;
+import com.vaadin.flow.server.frontend.scanner.ClassFinder;
import com.vaadin.flow.server.startup.ApplicationConfiguration;
/**
@@ -42,11 +45,15 @@
* collected into the bundle when the frontend is built. Redefining the
* interface therefore does not change what the browser can run: a call made
* after the change either runs the JavaScript the bundle was built with, or
- * finds no function at all when the redefinition renamed or added a method. The
- * dev loop escalates to a restart for this, but a class redefined straight from
- * an IDE reaches the application without going through it, and there is nothing
- * a hotswapper could apply in the browser instead - only a frontend build
- * produces the new function. So it says what happened, and what to do about it.
+ * finds no function at all when the redefinition renamed or added a method.
+ *
+ * With the frontend dev server running, the file the functions are generated
+ * into is written again from what the interfaces now declare. The dev server
+ * replaces the module in every browser that has it, the file registers the new
+ * functions, and a call made afterwards runs them - no restart, and nothing is
+ * compiled from a string in the browser, since the dev server serves the file
+ * it just read. Without the dev server, a bundle is what the browser runs and
+ * only a build produces a new one, so the change is reported instead.
*
* The comparison is against the generated file the bundle was built from, which
* is what the browser can run, and it uses the same rendering the build wrote,
@@ -66,18 +73,72 @@ public void onClassesChange(HotswapClassEvent event) {
return;
}
- String generated = readGeneratedInvokers(event.getVaadinService());
+ VaadinService service = event.getVaadinService();
+ ApplicationConfiguration configuration = ApplicationConfiguration
+ .get(service.getContext());
+ File generatedFile = generatedInvokersFile(configuration);
+ String generated = readGeneratedInvokers(generatedFile);
+
List stale = new ArrayList<>();
for (Class> invoker : invokers) {
if (!isInBundle(invoker, generated)) {
stale.add(invoker.getName());
}
}
- if (!stale.isEmpty()) {
+ if (stale.isEmpty()) {
+ return;
+ }
+
+ if (regenerate(service, configuration, generatedFile)) {
+ getLogger().debug(
+ "Wrote the JavaScript declared by {} to {}, which the frontend dev server replaces in the browser",
+ String.join(", ", stale), generatedFile);
+ } else {
report(stale);
}
}
+ /**
+ * Writes what every invoker interface declares to the generated file, so
+ * the frontend dev server can replace the module in the browser.
+ *
+ * Only with the dev server running: what a browser has without it is a
+ * bundle, which this can not replace. The file is left alone when its
+ * content would not change, so the dev server is not told about an update
+ * that is not one.
+ *
+ * @return whether the file now holds what the interfaces declare
+ */
+ private static boolean regenerate(VaadinService service,
+ ApplicationConfiguration configuration, File generatedFile) {
+ if (generatedFile == null || configuration == null || configuration
+ .getMode() != Mode.DEVELOPMENT_FRONTEND_LIVERELOAD) {
+ return false;
+ }
+ Lookup lookup = service.getContext().getAttribute(Lookup.class);
+ ClassFinder classFinder = lookup == null ? null
+ : lookup.lookup(ClassFinder.class);
+ if (classFinder == null) {
+ return false;
+ }
+ try {
+ String content = TaskGenerateJsInvokers.fileContent(
+ classFinder.getAnnotatedClasses(JsInvoker.class));
+ if (generatedFile.exists()
+ && content.equals(Files.readString(generatedFile.toPath(),
+ StandardCharsets.UTF_8))) {
+ return true;
+ }
+ Files.createDirectories(generatedFile.toPath().getParent());
+ Files.writeString(generatedFile.toPath(), content,
+ StandardCharsets.UTF_8);
+ return true;
+ } catch (IOException | RuntimeException e) {
+ getLogger().debug("Could not write {}", generatedFile, e);
+ return false;
+ }
+ }
+
/**
* Says that the bundle does not carry what the given interfaces declare.
*
@@ -112,9 +173,8 @@ private static boolean isInBundle(Class> invoker, String generated) {
.contains(String.join(System.lineSeparator(), declared));
}
- private static String readGeneratedInvokers(VaadinService service) {
- ApplicationConfiguration configuration = ApplicationConfiguration
- .get(service.getContext());
+ private static File generatedInvokersFile(
+ ApplicationConfiguration configuration) {
if (configuration == null) {
return null;
}
@@ -123,16 +183,20 @@ private static String readGeneratedInvokers(VaadinService service) {
if (frontendFolder == null) {
return null;
}
- File generated = new File(
+ return new File(
FrontendUtils.getFrontendGeneratedFolder(frontendFolder),
FrontendUtils.JS_INVOKERS_FILE_NAME);
- if (!generated.exists()) {
+ }
+
+ private static String readGeneratedInvokers(File generatedFile) {
+ if (generatedFile == null || !generatedFile.exists()) {
return null;
}
try {
- return Files.readString(generated.toPath(), StandardCharsets.UTF_8);
+ return Files.readString(generatedFile.toPath(),
+ StandardCharsets.UTF_8);
} catch (IOException e) {
- getLogger().debug("Could not read {}", generated, e);
+ getLogger().debug("Could not read {}", generatedFile, e);
return null;
}
}
diff --git a/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapperTest.java b/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapperTest.java
index 3a6b81c3785..96bc4cb97b0 100644
--- a/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapperTest.java
+++ b/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapperTest.java
@@ -34,7 +34,10 @@
import com.vaadin.flow.dom.JsInvoker;
import com.vaadin.flow.internal.FrontendUtils;
import com.vaadin.flow.server.MockVaadinServletService;
+import com.vaadin.flow.server.Mode;
import com.vaadin.flow.server.frontend.TaskGenerateJsInvokers;
+import com.vaadin.flow.server.frontend.scanner.ClassFinder;
+import com.vaadin.flow.server.frontend.scanner.ClassFinder.DefaultClassFinder;
import com.vaadin.flow.server.startup.ApplicationConfiguration;
import com.vaadin.flow.server.startup.ApplicationConfigurationFactory;
import com.vaadin.tests.util.MockDeploymentConfiguration;
@@ -69,6 +72,7 @@ void report(List invokerNames) {
private TestHotswapper hotswapper;
private MockVaadinServletService service;
private File frontendFolder;
+ private ApplicationConfiguration configuration;
@BeforeEach
void setUp() {
@@ -77,15 +81,39 @@ void setUp() {
service = new MockVaadinServletService(
new MockDeploymentConfiguration());
- ApplicationConfiguration configuration = Mockito
- .mock(ApplicationConfiguration.class);
+ configuration = Mockito.mock(ApplicationConfiguration.class);
Mockito.when(configuration.getFrontendFolder())
.thenReturn(frontendFolder);
+ // What a browser runs without the frontend dev server is a bundle,
+ // which a case has to opt out of to get the file written again.
+ Mockito.when(configuration.getMode())
+ .thenReturn(Mode.DEVELOPMENT_BUNDLE);
Mockito.when(service.getLookup()
.lookup(ApplicationConfigurationFactory.class))
.thenReturn(context -> configuration);
}
+ /**
+ * Puts the frontend dev server in play, with the given interfaces as the
+ * ones the application declares.
+ */
+ private void withFrontendDevServer(Class>... invokers) {
+ Mockito.when(configuration.getMode())
+ .thenReturn(Mode.DEVELOPMENT_FRONTEND_LIVERELOAD);
+ Mockito.when(service.getLookup().lookup(ClassFinder.class))
+ .thenReturn(new DefaultClassFinder(Set.of(invokers)));
+ }
+
+ private String readGeneratedInvokers() throws IOException {
+ return Files
+ .readString(
+ new File(
+ FrontendUtils.getFrontendGeneratedFolder(
+ frontendFolder),
+ FrontendUtils.JS_INVOKERS_FILE_NAME).toPath(),
+ StandardCharsets.UTF_8);
+ }
+
private void writeGeneratedInvokers(String content) throws IOException {
File generated = FrontendUtils
.getFrontendGeneratedFolder(frontendFolder);
@@ -153,6 +181,37 @@ void noGeneratedFile_reported() {
"without a generated file nothing carries the declarations");
}
+ @Test
+ void frontendDevServerRunning_fileWrittenAgainInsteadOfReported()
+ throws IOException {
+ writeGeneratedInvokers(generatedFor(GreeterJs.class)
+ .replace("window.alert($0); this.focus()", "window.alert($0)"));
+ withFrontendDevServer(GreeterJs.class);
+
+ classesChanged(GreeterJs.class);
+
+ assertTrue(hotswapper.reported.isEmpty(),
+ "with the dev server the change is applied, not reported: "
+ + hotswapper.reported);
+ assertTrue(
+ readGeneratedInvokers()
+ .contains("window.alert($0); this.focus()"),
+ "the file should hold what the interface declares now");
+ assertTrue(readGeneratedInvokers().contains("import.meta.hot.accept()"),
+ "the file should accept its own update, so the dev server replaces just this module");
+ }
+
+ @Test
+ void frontendDevServerRunningWithoutTheFile_fileWritten()
+ throws IOException {
+ withFrontendDevServer(GreeterJs.class);
+
+ classesChanged(GreeterJs.class);
+
+ assertTrue(hotswapper.reported.isEmpty());
+ assertTrue(readGeneratedInvokers().contains(GreeterJs.class.getName()));
+ }
+
@Test
void noInvokerChanged_nothingReported() throws IOException {
writeGeneratedInvokers(generatedFor(GreeterJs.class));
From 062b95b662179a8374600e84dca93551bd6b0a41 Mon Sep 17 00:00:00 2001
From: "totally-not-ai[bot]"
<290682512+totally-not-ai[bot]@users.noreply.github.com>
Date: Fri, 18 Sep 2026 08:15:47 +0000
Subject: [PATCH 11/57] fix: render the invoker file from the file and what
changed
Regenerating it went through a class finder from the context lookup,
which nothing puts there: the lookup resolved to nothing, so the write
never happened and every change was reported instead of applied. The
file is now rendered from the interfaces it already holds - which are
what the browser has, and none of them changed - plus the ones that just
changed, so nothing has to scan, and an interface that was only now
annotated gets in as well.
Writing is also no longer taken for success on its own: what the file
holds afterwards is compared with the declarations again, and whatever it
does not cover is reported, so a change nobody can apply is never
silently swallowed.
---
.../frontend/TaskGenerateJsInvokers.java | 33 +++++++
.../hotswap/impl/JsInvokerHotswapper.java | 94 ++++++++++++-------
.../hotswap/impl/JsInvokerHotswapperTest.java | 56 +++++++++--
3 files changed, 139 insertions(+), 44 deletions(-)
diff --git a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokers.java b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokers.java
index 91ca50b4c05..f59b0444875 100644
--- a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokers.java
+++ b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokers.java
@@ -21,6 +21,8 @@
import java.util.Collection;
import java.util.Comparator;
import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
import java.util.stream.IntStream;
import com.vaadin.flow.dom.JsExpression;
@@ -46,6 +48,9 @@
*/
public class TaskGenerateJsInvokers extends AbstractTaskClientGenerator {
+ private static final Pattern INVOKER_KEY = Pattern
+ .compile("window\\.Vaadin\\.Flow\\.jsInvokers\\[\"([^\"]+)\"\\] =");
+
private final Options options;
TaskGenerateJsInvokers(Options options) {
@@ -97,6 +102,34 @@ public static String fileContent(Collection> invokers) {
return String.join(System.lineSeparator(), lines);
}
+ /**
+ * Reads back the names of the invoker interfaces a generated file
+ * registers, which is what a browser that has the file can run.
+ *
+ * Exposed together with {@link #invokerLines(Class)} so that the format
+ * this class writes is also read here, and a caller which has to render the
+ * file again - the hotswap path - can keep the interfaces that are in it.
+ *
+ * @param fileContent
+ * the content of a generated file, or null
+ * @return the interface names the file registers, in the order it registers
+ * them
+ */
+ public static List invokerNames(String fileContent) {
+ List names = new ArrayList<>();
+ if (fileContent == null) {
+ return names;
+ }
+ Matcher matcher = INVOKER_KEY.matcher(fileContent);
+ while (matcher.find()) {
+ String name = matcher.group(1);
+ if (!names.contains(name)) {
+ names.add(name);
+ }
+ }
+ return names;
+ }
+
/**
* Renders what one invoker interface contributes to the generated file: the
* registration of its interface name, and one function per method that
diff --git a/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapper.java b/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapper.java
index 5550f5a3c2f..1a42f834cfe 100644
--- a/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapper.java
+++ b/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapper.java
@@ -19,22 +19,21 @@
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
-import java.util.ArrayList;
+import java.util.Collection;
+import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.vaadin.base.devserver.hotswap.HotswapClassEvent;
import com.vaadin.base.devserver.hotswap.VaadinHotswapper;
-import com.vaadin.flow.di.Lookup;
import com.vaadin.flow.dom.JsExpression;
import com.vaadin.flow.dom.JsInvoker;
import com.vaadin.flow.internal.FrontendUtils;
import com.vaadin.flow.server.Mode;
-import com.vaadin.flow.server.VaadinService;
import com.vaadin.flow.server.frontend.TaskGenerateJsInvokers;
-import com.vaadin.flow.server.frontend.scanner.ClassFinder;
import com.vaadin.flow.server.startup.ApplicationConfiguration;
/**
@@ -73,70 +72,95 @@ public void onClassesChange(HotswapClassEvent event) {
return;
}
- VaadinService service = event.getVaadinService();
ApplicationConfiguration configuration = ApplicationConfiguration
- .get(service.getContext());
+ .get(event.getVaadinService().getContext());
File generatedFile = generatedInvokersFile(configuration);
String generated = readGeneratedInvokers(generatedFile);
- List stale = new ArrayList<>();
- for (Class> invoker : invokers) {
- if (!isInBundle(invoker, generated)) {
- stale.add(invoker.getName());
- }
- }
+ List> stale = invokers.stream()
+ .filter(invoker -> !isInBundle(invoker, generated)).toList();
if (stale.isEmpty()) {
return;
}
- if (regenerate(service, configuration, generatedFile)) {
+ String applied = hotApply(configuration, generatedFile, generated,
+ invokers);
+ // What the file holds now is what a browser can run, so anything the
+ // rendering did not cover is still a change nobody can apply
+ List unresolved = stale.stream()
+ .filter(invoker -> !isInBundle(invoker, applied))
+ .map(Class::getName).toList();
+ if (unresolved.isEmpty()) {
getLogger().debug(
"Wrote the JavaScript declared by {} to {}, which the frontend dev server replaces in the browser",
- String.join(", ", stale), generatedFile);
+ stale.stream().map(Class::getName).toList(), generatedFile);
} else {
- report(stale);
+ report(unresolved);
}
}
/**
- * Writes what every invoker interface declares to the generated file, so
- * the frontend dev server can replace the module in the browser.
+ * Writes the generated file again from what the invoker interfaces declare,
+ * so the frontend dev server can replace the module in the browser.
*
* Only with the dev server running: what a browser has without it is a
* bundle, which this can not replace. The file is left alone when its
* content would not change, so the dev server is not told about an update
* that is not one.
*
- * @return whether the file now holds what the interfaces declare
+ * @return the content the file holds afterwards, which is the content it
+ * held already when nothing could be written
*/
- private static boolean regenerate(VaadinService service,
- ApplicationConfiguration configuration, File generatedFile) {
+ private static String hotApply(ApplicationConfiguration configuration,
+ File generatedFile, String generated,
+ List> changedInvokers) {
if (generatedFile == null || configuration == null || configuration
.getMode() != Mode.DEVELOPMENT_FRONTEND_LIVERELOAD) {
- return false;
- }
- Lookup lookup = service.getContext().getAttribute(Lookup.class);
- ClassFinder classFinder = lookup == null ? null
- : lookup.lookup(ClassFinder.class);
- if (classFinder == null) {
- return false;
+ return generated;
}
try {
- String content = TaskGenerateJsInvokers.fileContent(
- classFinder.getAnnotatedClasses(JsInvoker.class));
- if (generatedFile.exists()
- && content.equals(Files.readString(generatedFile.toPath(),
- StandardCharsets.UTF_8))) {
- return true;
+ String content = TaskGenerateJsInvokers
+ .fileContent(invokersToRender(generated, changedInvokers));
+ if (content.equals(generated)) {
+ return generated;
}
Files.createDirectories(generatedFile.toPath().getParent());
Files.writeString(generatedFile.toPath(), content,
StandardCharsets.UTF_8);
- return true;
+ return content;
} catch (IOException | RuntimeException e) {
getLogger().debug("Could not write {}", generatedFile, e);
- return false;
+ return generated;
+ }
+ }
+
+ /**
+ * The interfaces the file has to hold: the ones it holds already, since
+ * those are what the browser can run and none of them changed, plus the
+ * ones that just changed - which is also how an interface that was only now
+ * annotated gets in, without anything having scanned for it.
+ *
+ * An interface the file holds and the application no longer has is left
+ * out, and one whose annotation was removed keeps its functions in the file
+ * with nothing calling them, until a build renders it again.
+ */
+ private static Collection> invokersToRender(String generated,
+ List> changedInvokers) {
+ Map> byName = new LinkedHashMap<>();
+ changedInvokers
+ .forEach(invoker -> byName.put(invoker.getName(), invoker));
+ ClassLoader classLoader = changedInvokers.get(0).getClassLoader();
+ for (String name : TaskGenerateJsInvokers.invokerNames(generated)) {
+ if (byName.containsKey(name)) {
+ continue;
+ }
+ try {
+ byName.put(name, Class.forName(name, false, classLoader));
+ } catch (ClassNotFoundException | LinkageError e) {
+ getLogger().debug("Could not load the invoker {}", name, e);
+ }
}
+ return byName.values();
}
/**
diff --git a/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapperTest.java b/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapperTest.java
index 96bc4cb97b0..cdc01c737b2 100644
--- a/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapperTest.java
+++ b/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapperTest.java
@@ -36,8 +36,6 @@
import com.vaadin.flow.server.MockVaadinServletService;
import com.vaadin.flow.server.Mode;
import com.vaadin.flow.server.frontend.TaskGenerateJsInvokers;
-import com.vaadin.flow.server.frontend.scanner.ClassFinder;
-import com.vaadin.flow.server.frontend.scanner.ClassFinder.DefaultClassFinder;
import com.vaadin.flow.server.startup.ApplicationConfiguration;
import com.vaadin.flow.server.startup.ApplicationConfigurationFactory;
import com.vaadin.tests.util.MockDeploymentConfiguration;
@@ -53,6 +51,12 @@ interface GreeterJs extends Serializable {
void showGreeting(String greeting);
}
+ @JsInvoker
+ interface CounterJs extends Serializable {
+ @JsExpression("this.count = ($0 || 0) + 1")
+ void count(Integer from);
+ }
+
static class NotAnInvoker {
}
@@ -94,14 +98,12 @@ void setUp() {
}
/**
- * Puts the frontend dev server in play, with the given interfaces as the
- * ones the application declares.
+ * Puts the frontend dev server in play, which is what can replace the
+ * generated file in a running browser.
*/
- private void withFrontendDevServer(Class>... invokers) {
+ private void withFrontendDevServer() {
Mockito.when(configuration.getMode())
.thenReturn(Mode.DEVELOPMENT_FRONTEND_LIVERELOAD);
- Mockito.when(service.getLookup().lookup(ClassFinder.class))
- .thenReturn(new DefaultClassFinder(Set.of(invokers)));
}
private String readGeneratedInvokers() throws IOException {
@@ -186,7 +188,7 @@ void frontendDevServerRunning_fileWrittenAgainInsteadOfReported()
throws IOException {
writeGeneratedInvokers(generatedFor(GreeterJs.class)
.replace("window.alert($0); this.focus()", "window.alert($0)"));
- withFrontendDevServer(GreeterJs.class);
+ withFrontendDevServer();
classesChanged(GreeterJs.class);
@@ -204,7 +206,7 @@ void frontendDevServerRunning_fileWrittenAgainInsteadOfReported()
@Test
void frontendDevServerRunningWithoutTheFile_fileWritten()
throws IOException {
- withFrontendDevServer(GreeterJs.class);
+ withFrontendDevServer();
classesChanged(GreeterJs.class);
@@ -212,6 +214,42 @@ void frontendDevServerRunningWithoutTheFile_fileWritten()
assertTrue(readGeneratedInvokers().contains(GreeterJs.class.getName()));
}
+ @Test
+ void invokerTheFileNeverHeldOf_writtenBesideTheOnesItHolds()
+ throws IOException {
+ // What annotating an interface that the file was generated without
+ // looks like: nothing has scanned for it, and the interfaces the file
+ // does hold have to stay in it.
+ writeGeneratedInvokers(generatedFor(CounterJs.class));
+ withFrontendDevServer();
+
+ classesChanged(GreeterJs.class);
+
+ assertTrue(hotswapper.reported.isEmpty(),
+ "the file can hold both, so there is nothing to report: "
+ + hotswapper.reported);
+ String written = readGeneratedInvokers();
+ assertTrue(written.contains(GreeterJs.class.getName()),
+ "the interface that changed should be in the file: " + written);
+ assertTrue(written.contains(CounterJs.class.getName()),
+ "the interface the file held should still be in it: "
+ + written);
+ }
+
+ @Test
+ void frontendDevServerRunningButFileNotWritable_reported()
+ throws IOException {
+ // A directory where the file belongs: nothing can be written, so the
+ // change is reported rather than passing as applied.
+ new File(FrontendUtils.getFrontendGeneratedFolder(frontendFolder),
+ FrontendUtils.JS_INVOKERS_FILE_NAME).mkdirs();
+ withFrontendDevServer();
+
+ classesChanged(GreeterJs.class);
+
+ assertEquals(List.of(GreeterJs.class.getName()), hotswapper.reported);
+ }
+
@Test
void noInvokerChanged_nothingReported() throws IOException {
writeGeneratedInvokers(generatedFor(GreeterJs.class));
From bbf7fe0f7880cf68b180f9525205937c97864807 Mon Sep 17 00:00:00 2001
From: "totally-not-ai[bot]"
<290682512+totally-not-ai[bot]@users.noreply.github.com>
Date: Fri, 18 Sep 2026 09:07:12 +0000
Subject: [PATCH 12/57] refactor: move the invoker types out of dom and nest
the focus invoker
The types are not about the DOM: the same declarations are what
Page.executeJs would be invoked through, so they now live in
com.vaadin.flow.js.
The interface behind Focusable is a nested interface of it, rather than a
file of its own, so the declarations sit with the code that calls them.
Its two focus methods are one: passing no options passes null, which a
browser reads as the empty set of options it would use anyway, so the
choice between them is gone from both the interface and the caller. An
argument of a call may therefore be null.
---
.../frontend/TaskGenerateJsInvokers.java | 6 +-
.../frontend/TaskGenerateJsInvokersTest.java | 4 +-
.../com/vaadin/flow/component/FocusJs.java | 88 -------------------
.../com/vaadin/flow/component/Focusable.java | 64 ++++++++++++--
.../flow/component/internal/UIInternals.java | 2 +-
.../java/com/vaadin/flow/dom/Element.java | 3 +
.../vaadin/flow/{dom => js}/JsExpression.java | 2 +-
.../vaadin/flow/{dom => js}/JsInvoker.java | 2 +-
.../flow/{dom => js}/JsInvokerCall.java | 11 ++-
.../flow/server/communication/UidlWriter.java | 2 +-
.../vaadin/flow/component/FocusableTest.java | 43 ++++-----
.../java/com/vaadin/flow/dom/ElementTest.java | 3 +
.../flow/{dom => js}/JsInvokerCallTest.java | 2 +-
.../server/communication/UidlWriterTest.java | 6 +-
.../devserver/devloop/DevLoopRedefiner.java | 6 +-
.../hotswap/impl/JsInvokerHotswapper.java | 4 +-
.../startup/DevModeStartupListener.java | 2 +-
.../devloop/DevLoopRedefinerTest.java | 4 +-
.../hotswap/impl/JsInvokerHotswapperTest.java | 4 +-
.../startup/DevModeClassFinderTest.java | 2 +-
20 files changed, 117 insertions(+), 143 deletions(-)
delete mode 100644 flow-server/src/main/java/com/vaadin/flow/component/FocusJs.java
rename flow-server/src/main/java/com/vaadin/flow/{dom => js}/JsExpression.java (98%)
rename flow-server/src/main/java/com/vaadin/flow/{dom => js}/JsInvoker.java (98%)
rename flow-server/src/main/java/com/vaadin/flow/{dom => js}/JsInvokerCall.java (94%)
rename flow-server/src/test/java/com/vaadin/flow/{dom => js}/JsInvokerCallTest.java (99%)
diff --git a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokers.java b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokers.java
index f59b0444875..e3b3b554be3 100644
--- a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokers.java
+++ b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokers.java
@@ -25,10 +25,10 @@
import java.util.regex.Pattern;
import java.util.stream.IntStream;
-import com.vaadin.flow.dom.JsExpression;
-import com.vaadin.flow.dom.JsInvoker;
-import com.vaadin.flow.dom.JsInvokerCall;
import com.vaadin.flow.internal.FrontendUtils;
+import com.vaadin.flow.js.JsExpression;
+import com.vaadin.flow.js.JsInvoker;
+import com.vaadin.flow.js.JsInvokerCall;
import static com.vaadin.flow.internal.FrontendUtils.GENERATED;
import static com.vaadin.flow.internal.FrontendUtils.JS_INVOKERS_FILE_NAME;
diff --git a/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokersTest.java b/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokersTest.java
index c912d3b5ffe..7e9360373e1 100644
--- a/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokersTest.java
+++ b/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokersTest.java
@@ -25,8 +25,8 @@
import org.mockito.Mockito;
import com.vaadin.flow.di.Lookup;
-import com.vaadin.flow.dom.JsExpression;
-import com.vaadin.flow.dom.JsInvoker;
+import com.vaadin.flow.js.JsExpression;
+import com.vaadin.flow.js.JsInvoker;
import com.vaadin.flow.server.frontend.scanner.ClassFinder.DefaultClassFinder;
import static com.vaadin.flow.internal.FrontendUtils.FRONTEND;
diff --git a/flow-server/src/main/java/com/vaadin/flow/component/FocusJs.java b/flow-server/src/main/java/com/vaadin/flow/component/FocusJs.java
deleted file mode 100644
index e9212cd9526..00000000000
--- a/flow-server/src/main/java/com/vaadin/flow/component/FocusJs.java
+++ /dev/null
@@ -1,88 +0,0 @@
-/*
- * 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.io.Serializable;
-
-import tools.jackson.databind.node.ObjectNode;
-
-import com.vaadin.flow.dom.Element;
-import com.vaadin.flow.dom.JsExpression;
-import com.vaadin.flow.dom.JsInvoker;
-
-/**
- * The client-side operations behind {@link Focusable}, as an invoker interface
- * for {@link Element#getJsInvoker(Class)}.
- *
- * Focus and blur are marked as server-initiated for the client, so that the
- * resulting event reports {@code isFromClient() == false}. A driver of the
- * client side that implements this interface instead of running the scripts is
- * responsible for the same.
- *
- * An invoker interface extends {@link Serializable}, like everything else a
- * component can hold on to.
- */
-@JsInvoker
-public interface FocusJs extends Serializable {
-
- /**
- * Focuses the element with browser default options.
- */
- @JsExpression("""
- setTimeout(() => {
- try {
- this._nextFocusIsFromClient = false;
- this.focus();
- } finally {
- this._nextFocusIsFromClient = true;
- }
- }, 0)
- """)
- void focus();
-
- /**
- * Focuses the element with the given options.
- *
- * @param options
- * the options of the browser's focus function
- */
- @JsExpression("""
- setTimeout(() => {
- try {
- this._nextFocusIsFromClient = false;
- this.focus($0);
- } finally {
- this._nextFocusIsFromClient = true;
- }
- }, 0)
- """)
- void focus(ObjectNode options);
-
- /**
- * Removes focus from the element.
- */
- @JsExpression("""
- setTimeout(() => {
- try {
- this._nextBlurIsFromClient = false;
- this.blur();
- } finally {
- this._nextBlurIsFromClient = true;
- }
- }, 0)
- """)
- void blur();
-}
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 9de143f0a59..8a2864551a2 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,8 +15,15 @@
*/
package com.vaadin.flow.component;
+import java.io.Serializable;
+
+import org.jspecify.annotations.Nullable;
import tools.jackson.databind.node.ObjectNode;
+import com.vaadin.flow.dom.Element;
+import com.vaadin.flow.js.JsExpression;
+import com.vaadin.flow.js.JsInvoker;
+
/**
* Represents a component that can gain and lose focus.
*
@@ -132,13 +139,8 @@ default int getTabIndex() {
* @since 25.0
*/
default void focus(FocusOption... options) {
- FocusJs focusJs = getElement().getJsInvoker(FocusJs.class);
- ObjectNode json = FocusOption.buildOptions(options);
- if (json == null) {
- focusJs.focus();
- } else {
- focusJs.focus(json);
- }
+ getElement().getJsInvoker(FocusJs.class)
+ .focus(FocusOption.buildOptions(options));
}
// for binary compatibility with the previous Vaadin versions
@@ -209,4 +211,52 @@ default ShortcutRegistration addFocusShortcut(Key key,
() -> new Component[] { thisComponent.getUI().get() },
event -> this.focus(), key).withModifiers(keyModifiers);
}
+
+ /**
+ * The client-side operations behind {@link Focusable}, as an invoker
+ * interface for {@link Element#getJsInvoker(Class)}.
+ *
+ * Focus and blur are marked as server-initiated for the client, so that the
+ * resulting event reports {@code isFromClient() == false}. A driver of the
+ * client side that implements this interface instead of running the scripts
+ * is responsible for the same.
+ */
+ @JsInvoker
+ interface FocusJs extends Serializable {
+
+ /**
+ * Focuses the element.
+ *
+ * @param options
+ * the options of the browser's focus function,
+ * or null for its defaults, which is what the
+ * browser makes of an empty set of options
+ */
+ @JsExpression("""
+ setTimeout(() => {
+ try {
+ this._nextFocusIsFromClient = false;
+ this.focus($0);
+ } finally {
+ this._nextFocusIsFromClient = true;
+ }
+ }, 0)
+ """)
+ void focus(@Nullable ObjectNode options);
+
+ /**
+ * Removes focus from the element.
+ */
+ @JsExpression("""
+ setTimeout(() => {
+ try {
+ this._nextBlurIsFromClient = false;
+ this.blur();
+ } finally {
+ this._nextBlurIsFromClient = true;
+ }
+ }, 0)
+ """)
+ void blur();
+ }
}
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 1691a3a7fb3..088c2b0349d 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,7 +62,6 @@
import com.vaadin.flow.di.Instantiator;
import com.vaadin.flow.dom.Element;
import com.vaadin.flow.dom.ElementUtil;
-import com.vaadin.flow.dom.JsInvokerCall;
import com.vaadin.flow.dom.impl.BasicElementStateProvider;
import com.vaadin.flow.function.DeploymentConfiguration;
import com.vaadin.flow.internal.ActiveStyleSheetTracker;
@@ -77,6 +76,7 @@
import com.vaadin.flow.internal.nodefeature.PollConfigurationMap;
import com.vaadin.flow.internal.nodefeature.PushConfigurationMap;
import com.vaadin.flow.internal.nodefeature.ReconnectDialogConfigurationMap;
+import com.vaadin.flow.js.JsInvokerCall;
import com.vaadin.flow.router.AfterNavigationListener;
import com.vaadin.flow.router.BeforeEnterListener;
import com.vaadin.flow.router.BeforeLeaveEvent.ContinueNavigationAction;
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 94a3a8c6a37..9d89020b1d0 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
@@ -67,6 +67,9 @@
import com.vaadin.flow.internal.StateNode;
import com.vaadin.flow.internal.nodefeature.SignalBindingFeature;
import com.vaadin.flow.internal.nodefeature.VirtualChildrenList;
+import com.vaadin.flow.js.JsExpression;
+import com.vaadin.flow.js.JsInvoker;
+import com.vaadin.flow.js.JsInvokerCall;
import com.vaadin.flow.server.AbstractStreamResource;
import com.vaadin.flow.server.Command;
import com.vaadin.flow.server.StreamResource;
diff --git a/flow-server/src/main/java/com/vaadin/flow/dom/JsExpression.java b/flow-server/src/main/java/com/vaadin/flow/js/JsExpression.java
similarity index 98%
rename from flow-server/src/main/java/com/vaadin/flow/dom/JsExpression.java
rename to flow-server/src/main/java/com/vaadin/flow/js/JsExpression.java
index 7d3475f895d..c29e0cbff37 100644
--- a/flow-server/src/main/java/com/vaadin/flow/dom/JsExpression.java
+++ b/flow-server/src/main/java/com/vaadin/flow/js/JsExpression.java
@@ -13,7 +13,7 @@
* License for the specific language governing permissions and limitations under
* the License.
*/
-package com.vaadin.flow.dom;
+package com.vaadin.flow.js;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
diff --git a/flow-server/src/main/java/com/vaadin/flow/dom/JsInvoker.java b/flow-server/src/main/java/com/vaadin/flow/js/JsInvoker.java
similarity index 98%
rename from flow-server/src/main/java/com/vaadin/flow/dom/JsInvoker.java
rename to flow-server/src/main/java/com/vaadin/flow/js/JsInvoker.java
index f52c3d30b1e..9678d15e62f 100644
--- a/flow-server/src/main/java/com/vaadin/flow/dom/JsInvoker.java
+++ b/flow-server/src/main/java/com/vaadin/flow/js/JsInvoker.java
@@ -13,7 +13,7 @@
* License for the specific language governing permissions and limitations under
* the License.
*/
-package com.vaadin.flow.dom;
+package com.vaadin.flow.js;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
diff --git a/flow-server/src/main/java/com/vaadin/flow/dom/JsInvokerCall.java b/flow-server/src/main/java/com/vaadin/flow/js/JsInvokerCall.java
similarity index 94%
rename from flow-server/src/main/java/com/vaadin/flow/dom/JsInvokerCall.java
rename to flow-server/src/main/java/com/vaadin/flow/js/JsInvokerCall.java
index 7ec91b6916e..f5cdb27c024 100644
--- a/flow-server/src/main/java/com/vaadin/flow/dom/JsInvokerCall.java
+++ b/flow-server/src/main/java/com/vaadin/flow/js/JsInvokerCall.java
@@ -13,12 +13,14 @@
* License for the specific language governing permissions and limitations under
* the License.
*/
-package com.vaadin.flow.dom;
+package com.vaadin.flow.js;
import java.io.Serializable;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
+import java.util.ArrayList;
import java.util.Arrays;
+import java.util.Collections;
import java.util.List;
import java.util.Objects;
@@ -44,7 +46,8 @@
* @param methodName
* the name of the called method
* @param arguments
- * the arguments of the call, in declaration order
+ * the arguments of the call, in declaration order, any of which may
+ * be null
*/
public record JsInvokerCall(Class> invokerType, String methodName,
List arguments) implements Serializable {
@@ -62,7 +65,9 @@ public record JsInvokerCall(Class> invokerType, String methodName,
public JsInvokerCall {
Objects.requireNonNull(invokerType, "Invoker type cannot be null");
Objects.requireNonNull(methodName, "Method name cannot be null");
- arguments = List.copyOf(arguments);
+ // Copied rather than List.copyOf, which rejects a null element: an
+ // argument may be null, and the client gets it as null
+ arguments = Collections.unmodifiableList(new ArrayList<>(arguments));
}
/**
diff --git a/flow-server/src/main/java/com/vaadin/flow/server/communication/UidlWriter.java b/flow-server/src/main/java/com/vaadin/flow/server/communication/UidlWriter.java
index 429867d6401..3eccdd020f1 100644
--- a/flow-server/src/main/java/com/vaadin/flow/server/communication/UidlWriter.java
+++ b/flow-server/src/main/java/com/vaadin/flow/server/communication/UidlWriter.java
@@ -43,7 +43,6 @@
import com.vaadin.flow.component.internal.DependencyList;
import com.vaadin.flow.component.internal.PendingJavaScriptInvocation;
import com.vaadin.flow.component.internal.UIInternals;
-import com.vaadin.flow.dom.JsInvokerCall;
import com.vaadin.flow.function.SerializableConsumer;
import com.vaadin.flow.internal.JacksonCodec;
import com.vaadin.flow.internal.JacksonUtils;
@@ -57,6 +56,7 @@
import com.vaadin.flow.internal.nodefeature.ComponentMapping;
import com.vaadin.flow.internal.nodefeature.ReturnChannelMap;
import com.vaadin.flow.internal.nodefeature.ReturnChannelRegistration;
+import com.vaadin.flow.js.JsInvokerCall;
import com.vaadin.flow.server.DependencyFilter;
import com.vaadin.flow.server.SystemMessages;
import com.vaadin.flow.server.VaadinService;
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 a7c112656b5..6fef3d25846 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
@@ -16,6 +16,7 @@
package com.vaadin.flow.component;
import java.util.ArrayList;
+import java.util.Collections;
import java.util.List;
import org.junit.jupiter.api.Test;
@@ -25,11 +26,12 @@
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.JsInvokerCall;
+import com.vaadin.flow.js.JsInvokerCall;
import com.vaadin.tests.util.MockUI;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class FocusableTest {
@@ -259,16 +261,16 @@ void focus_withoutOptions_generatesCorrectJS() {
.getExpression();
assertTrue(expression.contains("setTimeout"),
"Should contain setTimeout wrapper");
- assertTrue(expression.contains(".focus()"),
- "Should contain focus call without parameters");
- assertFalse(expression.contains(".focus($0)"),
- "Should not contain focus call with parameter");
+ assertTrue(expression.contains(".focus($0)"),
+ "Should contain focus call with the options parameter");
- // Check the parameters
+ // Check the parameters: the options are null, which the browser makes
+ // the same as calling focus() with none
List params = invocations.getFirst().getInvocation()
.getParameters();
- assertEquals(1, params.size(),
- "Should have exactly 1 wrapped parameter (no user-provided parameters)");
+ assertEquals(2, params.size(),
+ "Should have the options and the element the function runs on");
+ assertNull(params.getFirst(), "Should pass no options");
}
@Test
@@ -277,7 +279,7 @@ void focus_invocationCarriesTheInvokerCallWithTheOptions() {
component.focus(PreventScroll.ENABLED);
JsInvokerCall call = dumpSingleCall();
- assertEquals(FocusJs.class, call.invokerType());
+ assertEquals(Focusable.FocusJs.class, call.invokerType());
assertEquals("focus", call.methodName());
assertEquals("{\"preventScroll\":true}",
call.arguments().get(0).toString(),
@@ -289,8 +291,11 @@ void focusWithoutOptions_invocationCarriesTheNoArgumentCall() {
ui.add(component);
component.focus();
- assertEquals(new JsInvokerCall(FocusJs.class, "focus", List.of()),
- dumpSingleCall());
+ assertEquals(
+ new JsInvokerCall(Focusable.FocusJs.class, "focus",
+ Collections.singletonList(null)),
+ dumpSingleCall(),
+ "no options is the options of the browser, which is what it makes of none");
}
@Test
@@ -298,7 +303,8 @@ void blur_invocationCarriesTheBlurCall() {
ui.add(component);
component.blur();
- assertEquals(new JsInvokerCall(FocusJs.class, "blur", List.of()),
+ assertEquals(
+ new JsInvokerCall(Focusable.FocusJs.class, "blur", List.of()),
dumpSingleCall());
}
@@ -317,7 +323,7 @@ void pendingInvocations_runOnAnImplementationOfTheInvoker_plainJavaScriptLeftInt
for (PendingJavaScriptInvocation pending : ui
.dumpPendingJsInvocations()) {
JsInvokerCall call = pending.getInvocation().getInvokerCall();
- if (call != null && call.invokerType() == FocusJs.class) {
+ if (call != null && call.invokerType() == Focusable.FocusJs.class) {
call.invokeOn(new FocusSimulation(
Element.get(pending.getOwner()), log));
} else {
@@ -345,16 +351,11 @@ private JsInvokerCall dumpSingleCall() {
}
/**
- * What a browserless driver would register for {@link FocusJs}: the
- * server-side effect of the operations, with no JavaScript involved.
+ * What a browserless driver would register for {@link Focusable.FocusJs}:
+ * the server-side effect of the operations, with no JavaScript involved.
*/
private record FocusSimulation(Element target,
- List log) implements FocusJs {
-
- @Override
- public void focus() {
- log.add("focus " + target.getTag());
- }
+ List log) implements Focusable.FocusJs {
@Override
public void focus(ObjectNode options) {
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 20d42cabec4..cd7cb84a522 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
@@ -77,6 +77,9 @@
import com.vaadin.flow.internal.nodefeature.ReturnChannelMap;
import com.vaadin.flow.internal.nodefeature.ReturnChannelRegistration;
import com.vaadin.flow.internal.nodefeature.VirtualChildrenList;
+import com.vaadin.flow.js.JsExpression;
+import com.vaadin.flow.js.JsInvoker;
+import com.vaadin.flow.js.JsInvokerCall;
import com.vaadin.flow.server.ErrorEvent;
import com.vaadin.flow.server.MockVaadinServletService;
import com.vaadin.flow.server.StreamResource;
diff --git a/flow-server/src/test/java/com/vaadin/flow/dom/JsInvokerCallTest.java b/flow-server/src/test/java/com/vaadin/flow/js/JsInvokerCallTest.java
similarity index 99%
rename from flow-server/src/test/java/com/vaadin/flow/dom/JsInvokerCallTest.java
rename to flow-server/src/test/java/com/vaadin/flow/js/JsInvokerCallTest.java
index da9bdb67df2..0ae04e8319e 100644
--- a/flow-server/src/test/java/com/vaadin/flow/dom/JsInvokerCallTest.java
+++ b/flow-server/src/test/java/com/vaadin/flow/js/JsInvokerCallTest.java
@@ -13,7 +13,7 @@
* License for the specific language governing permissions and limitations under
* the License.
*/
-package com.vaadin.flow.dom;
+package com.vaadin.flow.js;
import java.io.Serializable;
import java.util.List;
diff --git a/flow-server/src/test/java/com/vaadin/flow/server/communication/UidlWriterTest.java b/flow-server/src/test/java/com/vaadin/flow/server/communication/UidlWriterTest.java
index e7a2a98d30a..d31e5b719ce 100644
--- a/flow-server/src/test/java/com/vaadin/flow/server/communication/UidlWriterTest.java
+++ b/flow-server/src/test/java/com/vaadin/flow/server/communication/UidlWriterTest.java
@@ -48,12 +48,12 @@
import com.vaadin.flow.di.Lookup;
import com.vaadin.flow.dom.Element;
import com.vaadin.flow.dom.ElementFactory;
-import com.vaadin.flow.dom.JsExpression;
-import com.vaadin.flow.dom.JsInvoker;
-import com.vaadin.flow.dom.JsInvokerCall;
import com.vaadin.flow.internal.BundleUtils;
import com.vaadin.flow.internal.JacksonUtils;
import com.vaadin.flow.internal.StateTree;
+import com.vaadin.flow.js.JsExpression;
+import com.vaadin.flow.js.JsInvoker;
+import com.vaadin.flow.js.JsInvokerCall;
import com.vaadin.flow.router.ParentLayout;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.router.RouteConfiguration;
diff --git a/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/devloop/DevLoopRedefiner.java b/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/devloop/DevLoopRedefiner.java
index 9fd1d82cb8a..349830e275e 100644
--- a/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/devloop/DevLoopRedefiner.java
+++ b/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/devloop/DevLoopRedefiner.java
@@ -57,15 +57,15 @@
import com.vaadin.flow.component.dependency.JavaScript;
import com.vaadin.flow.component.dependency.JsModule;
import com.vaadin.flow.component.dependency.NpmPackage;
-import com.vaadin.flow.dom.JsExpression;
-import com.vaadin.flow.dom.JsInvoker;
-import com.vaadin.flow.dom.JsInvokerCall;
import com.vaadin.flow.internal.AnnotationReader;
import com.vaadin.flow.internal.BrowserLiveReload;
import com.vaadin.flow.internal.BrowserLiveReloadAccessor;
import com.vaadin.flow.internal.DevModeHandler;
import com.vaadin.flow.internal.DevModeHandlerManager;
import com.vaadin.flow.internal.ThemeUtils;
+import com.vaadin.flow.js.JsExpression;
+import com.vaadin.flow.js.JsInvoker;
+import com.vaadin.flow.js.JsInvokerCall;
import com.vaadin.flow.server.VaadinService;
import com.vaadin.flow.theme.Theme;
diff --git a/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapper.java b/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapper.java
index 1a42f834cfe..6efbcc66b35 100644
--- a/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapper.java
+++ b/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapper.java
@@ -29,9 +29,9 @@
import com.vaadin.base.devserver.hotswap.HotswapClassEvent;
import com.vaadin.base.devserver.hotswap.VaadinHotswapper;
-import com.vaadin.flow.dom.JsExpression;
-import com.vaadin.flow.dom.JsInvoker;
import com.vaadin.flow.internal.FrontendUtils;
+import com.vaadin.flow.js.JsExpression;
+import com.vaadin.flow.js.JsInvoker;
import com.vaadin.flow.server.Mode;
import com.vaadin.flow.server.frontend.TaskGenerateJsInvokers;
import com.vaadin.flow.server.startup.ApplicationConfiguration;
diff --git a/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/startup/DevModeStartupListener.java b/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/startup/DevModeStartupListener.java
index ebecdfb9d0e..793ec719fa5 100644
--- a/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/startup/DevModeStartupListener.java
+++ b/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/startup/DevModeStartupListener.java
@@ -38,9 +38,9 @@
import com.vaadin.flow.component.dependency.StyleSheet;
import com.vaadin.flow.component.page.AppShellConfigurator;
import com.vaadin.flow.di.Lookup;
-import com.vaadin.flow.dom.JsInvoker;
import com.vaadin.flow.internal.DevModeHandlerManager;
import com.vaadin.flow.internal.Template;
+import com.vaadin.flow.js.JsInvoker;
import com.vaadin.flow.router.HasErrorParameter;
import com.vaadin.flow.router.Layout;
import com.vaadin.flow.router.Route;
diff --git a/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/devloop/DevLoopRedefinerTest.java b/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/devloop/DevLoopRedefinerTest.java
index 0bbb6eb16b3..13a625c3c7c 100644
--- a/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/devloop/DevLoopRedefinerTest.java
+++ b/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/devloop/DevLoopRedefinerTest.java
@@ -37,8 +37,8 @@
import com.vaadin.flow.component.dependency.JsModule;
import com.vaadin.flow.component.dependency.NpmPackage;
import com.vaadin.flow.component.page.AppShellConfigurator;
-import com.vaadin.flow.dom.JsExpression;
-import com.vaadin.flow.dom.JsInvoker;
+import com.vaadin.flow.js.JsExpression;
+import com.vaadin.flow.js.JsInvoker;
import com.vaadin.flow.theme.Theme;
import static org.junit.jupiter.api.Assertions.assertEquals;
diff --git a/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapperTest.java b/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapperTest.java
index cdc01c737b2..a5049ee611c 100644
--- a/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapperTest.java
+++ b/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapperTest.java
@@ -30,9 +30,9 @@
import org.mockito.Mockito;
import com.vaadin.base.devserver.hotswap.HotswapClassEvent;
-import com.vaadin.flow.dom.JsExpression;
-import com.vaadin.flow.dom.JsInvoker;
import com.vaadin.flow.internal.FrontendUtils;
+import com.vaadin.flow.js.JsExpression;
+import com.vaadin.flow.js.JsInvoker;
import com.vaadin.flow.server.MockVaadinServletService;
import com.vaadin.flow.server.Mode;
import com.vaadin.flow.server.frontend.TaskGenerateJsInvokers;
diff --git a/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/startup/DevModeClassFinderTest.java b/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/startup/DevModeClassFinderTest.java
index 3305fa6849f..5e0aea61958 100644
--- a/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/startup/DevModeClassFinderTest.java
+++ b/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/startup/DevModeClassFinderTest.java
@@ -37,8 +37,8 @@
import com.vaadin.flow.component.dependency.NpmPackage;
import com.vaadin.flow.component.dependency.StyleSheet;
import com.vaadin.flow.component.page.AppShellConfigurator;
-import com.vaadin.flow.dom.JsInvoker;
import com.vaadin.flow.internal.Template;
+import com.vaadin.flow.js.JsInvoker;
import com.vaadin.flow.router.HasErrorParameter;
import com.vaadin.flow.router.Layout;
import com.vaadin.flow.router.Route;
From 376dc86f61ae9056e75f16cd7d0c2f07f3f18f2a Mon Sep 17 00:00:00 2001
From: "totally-not-ai[bot]"
<290682512+totally-not-ai[bot]@users.noreply.github.com>
Date: Fri, 18 Sep 2026 09:15:24 +0000
Subject: [PATCH 13/57] fix: keep the javadoc of the invoker types resolvable
after the move
Their javadoc links the entry point they are called through, which
resolved while they sat in the same package as it. The import they need
for that is back, so the javadoc build has something to resolve again.
An argument being allowed to be null is also pinned where it is decided:
one case for a call keeping it and handing it to the implementation, the
way focusing without options does.
---
.../java/com/vaadin/flow/js/JsExpression.java | 2 ++
.../java/com/vaadin/flow/js/JsInvoker.java | 2 ++
.../com/vaadin/flow/js/JsInvokerCall.java | 2 ++
.../com/vaadin/flow/js/JsInvokerCallTest.java | 20 ++++++++++++++++++-
4 files changed, 25 insertions(+), 1 deletion(-)
diff --git a/flow-server/src/main/java/com/vaadin/flow/js/JsExpression.java b/flow-server/src/main/java/com/vaadin/flow/js/JsExpression.java
index c29e0cbff37..629013b5823 100644
--- a/flow-server/src/main/java/com/vaadin/flow/js/JsExpression.java
+++ b/flow-server/src/main/java/com/vaadin/flow/js/JsExpression.java
@@ -21,6 +21,8 @@
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
+import com.vaadin.flow.dom.Element;
+
/**
* The JavaScript that a method of a JS invoker interface runs, as a constant
* expression.
diff --git a/flow-server/src/main/java/com/vaadin/flow/js/JsInvoker.java b/flow-server/src/main/java/com/vaadin/flow/js/JsInvoker.java
index 9678d15e62f..7ed2bcf8a71 100644
--- a/flow-server/src/main/java/com/vaadin/flow/js/JsInvoker.java
+++ b/flow-server/src/main/java/com/vaadin/flow/js/JsInvoker.java
@@ -21,6 +21,8 @@
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
+import com.vaadin.flow.dom.Element;
+
/**
* Marks an interface whose methods declare the JavaScript they run with
* {@link JsExpression}, to be called through
diff --git a/flow-server/src/main/java/com/vaadin/flow/js/JsInvokerCall.java b/flow-server/src/main/java/com/vaadin/flow/js/JsInvokerCall.java
index f5cdb27c024..ffe3f010dd6 100644
--- a/flow-server/src/main/java/com/vaadin/flow/js/JsInvokerCall.java
+++ b/flow-server/src/main/java/com/vaadin/flow/js/JsInvokerCall.java
@@ -24,6 +24,8 @@
import java.util.List;
import java.util.Objects;
+import com.vaadin.flow.dom.Element;
+
/**
* A call made through {@link Element#getJsInvoker(Class)}: which invoker
* interface, which method of it, and the arguments that were passed.
diff --git a/flow-server/src/test/java/com/vaadin/flow/js/JsInvokerCallTest.java b/flow-server/src/test/java/com/vaadin/flow/js/JsInvokerCallTest.java
index 0ae04e8319e..6b7cec44f90 100644
--- a/flow-server/src/test/java/com/vaadin/flow/js/JsInvokerCallTest.java
+++ b/flow-server/src/test/java/com/vaadin/flow/js/JsInvokerCallTest.java
@@ -16,6 +16,8 @@
package com.vaadin.flow.js;
import java.io.Serializable;
+import java.util.Arrays;
+import java.util.Collections;
import java.util.List;
import org.junit.jupiter.api.Test;
@@ -64,7 +66,7 @@ public void ambiguous(int value) {
private static JsInvokerCall call(String methodName, Object... arguments) {
return new JsInvokerCall(GreeterJs.class, methodName,
- List.of(arguments));
+ Arrays.asList(arguments));
}
@Test
@@ -103,6 +105,22 @@ void invokeOn_implementation_runsTheMethod() {
assertEquals(List.of("Hello"), greeter.greetings);
}
+ @Test
+ void nullArgument_keptAndPassedToTheImplementation() {
+ // What a method whose value is optional is called with - the focus
+ // options of a browser, for one - so it has to survive the call and
+ // reach the implementation as it was.
+ JsInvokerCall call = call("showGreeting", (Object) null);
+ Greeter greeter = new Greeter();
+
+ assertEquals(Collections.singletonList(null), call.arguments());
+ assertEquals("showGreeting/1", call.getMethodId());
+
+ call.invokeOn(greeter);
+
+ assertEquals(Collections.singletonList(null), greeter.greetings);
+ }
+
@Test
void invokeOn_somethingElse_throws() {
IllegalArgumentException exception = assertThrows(
From dce444d8475474fc71399b078ebf52bff0a183b6 Mon Sep 17 00:00:00 2001
From: "totally-not-ai[bot]"
<290682512+totally-not-ai[bot]@users.noreply.github.com>
Date: Fri, 18 Sep 2026 11:25:28 +0000
Subject: [PATCH 14/57] feat: invoke page level JavaScript through an invoker
as well
Page.getJsInvoker(Class) declares and runs page level JavaScript the way
the element level entry point does, so a call made through it sends no
expression either and needs no unsafe-eval. It schedules the way
Page.executeJs does, so the two reach the client in the order they were
made.
A page invoker has no element to apply its function to, which the target
of an invocation now says, and the client runs such a function with no
`this` - page level JavaScript works on globals. Creating the invoker
itself is shared by the two entry points, so a method may return the same
things and an unusable interface is refused the same way wherever it came
from.
---
.../client/flow/ExecuteJavaScriptProcessor.ts | 29 +++--
.../flow/ExecuteJavaScriptProcessorTests.ts | 43 ++++++-
.../com/vaadin/flow/component/page/Page.java | 66 ++++++++++
.../java/com/vaadin/flow/dom/Element.java | 51 +-------
.../java/com/vaadin/flow/js/JsInvokers.java | 114 ++++++++++++++++++
.../flow/server/communication/UidlWriter.java | 12 +-
.../com/vaadin/flow/shared/JsonConstants.java | 7 ++
.../vaadin/flow/component/page/PageTest.java | 31 +++++
.../server/communication/UidlWriterTest.java | 24 ++++
9 files changed, 308 insertions(+), 69 deletions(-)
create mode 100644 flow-server/src/main/java/com/vaadin/flow/js/JsInvokers.java
diff --git a/flow-client/src/main/frontend/internal/client/flow/ExecuteJavaScriptProcessor.ts b/flow-client/src/main/frontend/internal/client/flow/ExecuteJavaScriptProcessor.ts
index 4def3338a4c..d01ad968234 100644
--- a/flow-client/src/main/frontend/internal/client/flow/ExecuteJavaScriptProcessor.ts
+++ b/flow-client/src/main/frontend/internal/client/flow/ExecuteJavaScriptProcessor.ts
@@ -76,6 +76,7 @@ export interface JsInvokerTarget {
invoker: string;
method: string;
arguments: number;
+ element?: boolean;
returns?: boolean;
}
@@ -246,19 +247,20 @@ export class ExecuteJavaScriptProcessor {
*
* @param target - the invoker interface and method to run
* @param parameters - the decoded parameters: the arguments of the call, the
- * element to apply the function to, and the return value channels
- * when the target declares them
+ * element to apply the function to when the target has one, and the
+ * return value channels when the target declares them
*/
protected invokeFromBundle(target: JsInvokerTarget, parameters: unknown[]): void {
const argumentCount = target.arguments;
+ const hasElement = target.element === true;
// The parameters are the arguments of the call, then the element to apply
- // the function to, then the two return value channels when the target
- // declares them. Nothing else may be in there, so a count that does not
- // add up means the invocation was not built by the server this client
- // talks to, and reading the element out of it by index would bind an
- // argument as `this`. Say so instead of running the call.
- const expectedCount = argumentCount + 1 + (target.returns === true ? 2 : 0);
+ // the function to when the target has one, then the two return value
+ // channels when the target declares them. Nothing else may be in there, so
+ // a count that does not add up means the invocation was not built by the
+ // server this client talks to, and reading the element out of it by index
+ // would bind an argument as `this`. Say so instead of running the call.
+ const expectedCount = argumentCount + (hasElement ? 1 : 0) + (target.returns === true ? 2 : 0);
if (parameters.length !== expectedCount) {
const message = `Expected ${expectedCount} parameters for ${target.invoker}.${target.method} but the invocation carries ${parameters.length}. Reload the page to pick up the current signature.`;
Console.error(message);
@@ -275,8 +277,9 @@ export class ExecuteJavaScriptProcessor {
return;
}
- const onSuccess = target.returns === true ? (parameters[argumentCount + 1] as ReturnChannel) : undefined;
- const onError = target.returns === true ? (parameters[argumentCount + 2] as ReturnChannel) : undefined;
+ const channelIndex = argumentCount + (hasElement ? 1 : 0);
+ const onSuccess = target.returns === true ? (parameters[channelIndex] as ReturnChannel) : undefined;
+ const onError = target.returns === true ? (parameters[channelIndex + 1] as ReturnChannel) : undefined;
const fn = findInvokerFunction(target.invoker, target.method);
if (fn === undefined) {
@@ -287,8 +290,10 @@ export class ExecuteJavaScriptProcessor {
}
// The element the invoker was obtained from is the parameter after the
- // arguments, and it is what the function runs against.
- const thisArg = parameters[argumentCount];
+ // arguments, and it is what the function runs against. A page invoker has
+ // no element, and its JavaScript works on globals rather than on a
+ // `this`.
+ const thisArg = hasElement ? parameters[argumentCount] : undefined;
try {
const result = fn.apply(thisArg, parameters.slice(0, argumentCount));
if (onSuccess !== undefined) {
diff --git a/flow-client/src/test/frontend/internal/client/flow/ExecuteJavaScriptProcessorTests.ts b/flow-client/src/test/frontend/internal/client/flow/ExecuteJavaScriptProcessorTests.ts
index 9b52e16f903..b7b2152da46 100644
--- a/flow-client/src/test/frontend/internal/client/flow/ExecuteJavaScriptProcessorTests.ts
+++ b/flow-client/src/test/frontend/internal/client/flow/ExecuteJavaScriptProcessorTests.ts
@@ -107,7 +107,9 @@ describe('ExecuteJavaScriptProcessor', () => {
});
const element = { tagName: 'div' };
- processor().execute([['Hello', element, { invoker: INVOKER, method: 'showGreeting/1', arguments: 1 }]]);
+ processor().execute([
+ ['Hello', element, { invoker: INVOKER, method: 'showGreeting/1', arguments: 1, element: true }]
+ ]);
expect(calls).to.have.lengthOf(1);
expect(calls[0].thisArg).to.equal(element);
@@ -124,7 +126,7 @@ describe('ExecuteJavaScriptProcessor', () => {
element,
(value: unknown) => resolved.push(value),
() => {},
- { invoker: INVOKER, method: 'readValue/0', arguments: 0, returns: true }
+ { invoker: INVOKER, method: 'readValue/0', arguments: 0, element: true, returns: true }
]
]);
// Settled in microtasks: a macrotask wait would also pick up the
@@ -143,7 +145,7 @@ describe('ExecuteJavaScriptProcessor', () => {
// One argument declared, but no element to apply the function to: the
// invocation and this client disagree about the signature.
- processor().execute([['Hello', { invoker: INVOKER, method: 'showGreeting/1', arguments: 1 }]]);
+ processor().execute([['Hello', { invoker: INVOKER, method: 'showGreeting/1', arguments: 1, element: true }]]);
expect(calls).to.equal(0);
});
@@ -162,7 +164,7 @@ describe('ExecuteJavaScriptProcessor', () => {
[
element,
(error: unknown) => errors.push(error),
- { invoker: INVOKER, method: 'readValue/0', arguments: 0, returns: true }
+ { invoker: INVOKER, method: 'readValue/0', arguments: 0, element: true, returns: true }
]
]);
@@ -179,12 +181,41 @@ describe('ExecuteJavaScriptProcessor', () => {
});
processor().execute([
- ['Hello', 'unexpected', { tagName: 'div' }, { invoker: INVOKER, method: 'showGreeting/1', arguments: 1 }]
+ [
+ 'Hello',
+ 'unexpected',
+ { tagName: 'div' },
+ { invoker: INVOKER, method: 'showGreeting/1', arguments: 1, element: true }
+ ]
]);
expect(calls).to.equal(0);
});
+ it('runs a call with no element without a this, and answers its channel', async () => {
+ // What a page invoker sends: the arguments, then the channels, and no
+ // element to apply the function to.
+ const thisArgs: unknown[] = [];
+ registerInvoker('readValue/0', function (this: unknown) {
+ thisArgs.push(this);
+ return 'answer';
+ });
+ const resolved: unknown[] = [];
+
+ processor().execute([
+ [
+ (value: unknown) => resolved.push(value),
+ () => {},
+ { invoker: INVOKER, method: 'readValue/0', arguments: 0, returns: true }
+ ]
+ ]);
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(thisArgs).to.eql([undefined]);
+ expect(resolved).to.eql(['answer']);
+ });
+
it('reports a function that is not in the bundle to the error channel', () => {
const errors: unknown[] = [];
const element = { tagName: 'div' };
@@ -194,7 +225,7 @@ describe('ExecuteJavaScriptProcessor', () => {
element,
() => {},
(error: unknown) => errors.push(error),
- { invoker: INVOKER, method: 'missing/0', arguments: 0, returns: true }
+ { invoker: INVOKER, method: 'missing/0', arguments: 0, element: true, returns: true }
]
]);
diff --git a/flow-server/src/main/java/com/vaadin/flow/component/page/Page.java b/flow-server/src/main/java/com/vaadin/flow/component/page/Page.java
index 849d9a90981..08a2ab2b3fb 100644
--- a/flow-server/src/main/java/com/vaadin/flow/component/page/Page.java
+++ b/flow-server/src/main/java/com/vaadin/flow/component/page/Page.java
@@ -40,6 +40,10 @@
import com.vaadin.flow.dom.JsFunction;
import com.vaadin.flow.function.SerializableConsumer;
import com.vaadin.flow.internal.UrlUtil;
+import com.vaadin.flow.js.JsExpression;
+import com.vaadin.flow.js.JsInvoker;
+import com.vaadin.flow.js.JsInvokerCall;
+import com.vaadin.flow.js.JsInvokers;
import com.vaadin.flow.server.InitParameters;
import com.vaadin.flow.shared.Registration;
import com.vaadin.flow.shared.ui.Dependency;
@@ -316,6 +320,68 @@ public void addDynamicImport(String expression) {
}
// When updating JavaDocs here, keep in sync with Element.executeJavaScript
+ /**
+ * Gets an invoker for the JavaScript that the given interface declares, for
+ * this page.
+ *
+ * The interface is annotated with {@link JsInvoker} and each of its methods
+ * declares the JavaScript it runs with {@link JsExpression}. Calling a
+ * method runs that JavaScript in the browser with the method arguments as
+ * its parameters:
+ *
+ *
+ * @JsInvoker
+ * public interface ClipboardJs extends Serializable {
+ * @JsExpression("return navigator.clipboard.readText()")
+ * PendingJavaScriptResult readText();
+ * }
+ *
+ * page.getJsInvoker(ClipboardJs.class).readText().then(String.class,
+ * text -> ...);
+ *
+ *
+ * Unlike {@link #executeJs(String, Object...)}, nothing about the
+ * JavaScript is decided at the call site: the build collects the
+ * declarations of every invoker interface into the bundle, and the client
+ * runs the collected function after looking it up by interface and method.
+ * No expression is sent and none is compiled in the browser, so the call
+ * works under a content security policy without unsafe-eval.
+ *
+ * A method of a page invoker runs without a this, so its
+ * JavaScript works on globals - which is what page-level JavaScript does
+ * anyway. Use {@link Element#getJsInvoker(Class)} for JavaScript that acts
+ * on an element.
+ *
+ * A method returns either void or
+ * {@link PendingJavaScriptResult}.
+ *
+ * @param
+ * the invoker interface type
+ * @param invokerType
+ * the invoker interface, not null
+ * @return an invoker for this page, not null
+ */
+ public T getJsInvoker(Class invokerType) {
+ return JsInvokers.create(invokerType, this::scheduleInvokerCall);
+ }
+
+ /**
+ * Schedules a call made through a page invoker, the way
+ * {@link #executeJs(String, Object...)} schedules an expression, so the two
+ * reach the client in the order they were made. The parameters are the
+ * arguments of the call and nothing else: there is no element to apply the
+ * function to.
+ */
+ private PendingJavaScriptResult scheduleInvokerCall(JsInvokerCall call) {
+ JavaScriptInvocation invocation = new JavaScriptInvocation(call,
+ call.getExpression(), call.arguments().toArray());
+
+ PendingJavaScriptInvocation execution = new PendingJavaScriptInvocation(
+ ui.getInternals().getStateTree().getRootNode(), invocation);
+ ui.getInternals().addJavaScriptInvocation(execution);
+ return execution;
+ }
+
/**
* Asynchronously runs the given JavaScript expression in the browser.
*
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 9d89020b1d0..c6aa8bd2993 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
@@ -16,9 +16,6 @@
package com.vaadin.flow.dom;
import java.io.Serializable;
-import java.lang.reflect.InvocationHandler;
-import java.lang.reflect.Method;
-import java.lang.reflect.Proxy;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
@@ -70,6 +67,7 @@
import com.vaadin.flow.js.JsExpression;
import com.vaadin.flow.js.JsInvoker;
import com.vaadin.flow.js.JsInvokerCall;
+import com.vaadin.flow.js.JsInvokers;
import com.vaadin.flow.server.AbstractStreamResource;
import com.vaadin.flow.server.Command;
import com.vaadin.flow.server.StreamResource;
@@ -1977,53 +1975,8 @@ public PendingJavaScriptResult executeJs(String expression,
* the invoker interface, not null
* @return an invoker bound to this element, not null
*/
- @SuppressWarnings("unchecked")
public T getJsInvoker(Class invokerType) {
- Objects.requireNonNull(invokerType, "Invoker type cannot be null");
- if (!invokerType.isInterface()) {
- throw new IllegalArgumentException(
- invokerType.getName() + " is not an interface");
- }
- if (!invokerType.isAnnotationPresent(JsInvoker.class)) {
- throw new IllegalArgumentException(invokerType.getName()
- + " is not annotated with @JsInvoker, so the build does not"
- + " collect its JavaScript into the bundle");
- }
- return (T) Proxy.newProxyInstance(invokerType.getClassLoader(),
- new Class>[] { invokerType },
- new JsInvokerHandler(this, invokerType));
- }
-
- /**
- * Turns a call on a JS invoker interface into a scheduled invocation that
- * carries the call.
- */
- private record JsInvokerHandler(Element element,
- Class> invokerType) implements InvocationHandler, Serializable {
-
- @Override
- public Object invoke(Object proxy, Method method, Object[] args)
- throws Throwable {
- if (method.getDeclaringClass() == Object.class) {
- return method.invoke(this, args);
- }
- Class> returnType = method.getReturnType();
- boolean returnsResult = returnType
- .isAssignableFrom(PendingJavaScriptResult.class);
- // Checked before scheduling, so that a method the invoker can not
- // answer does not run in the browser either
- if (returnType != void.class && !returnsResult) {
- throw new IllegalStateException("Method " + method.getName()
- + " of " + invokerType.getName()
- + " must return void or PendingJavaScriptResult");
- }
- List arguments = args == null ? List.of()
- : Arrays.asList(args);
- PendingJavaScriptResult result = element
- .scheduleInvokerCall(new JsInvokerCall(invokerType,
- method.getName(), arguments));
- return returnsResult ? result : null;
- }
+ return JsInvokers.create(invokerType, this::scheduleInvokerCall);
}
private PendingJavaScriptResult scheduleExecuteJs(String expression,
diff --git a/flow-server/src/main/java/com/vaadin/flow/js/JsInvokers.java b/flow-server/src/main/java/com/vaadin/flow/js/JsInvokers.java
new file mode 100644
index 00000000000..b8ce19be215
--- /dev/null
+++ b/flow-server/src/main/java/com/vaadin/flow/js/JsInvokers.java
@@ -0,0 +1,114 @@
+/*
+ * 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.js;
+
+import java.io.Serializable;
+import java.lang.reflect.InvocationHandler;
+import java.lang.reflect.Method;
+import java.lang.reflect.Proxy;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Objects;
+
+import com.vaadin.flow.component.page.PendingJavaScriptResult;
+import com.vaadin.flow.function.SerializableFunction;
+
+/**
+ * Creates the invokers that the entry points hand out, so that an invoker
+ * behaves the same whichever of them it came from.
+ *
+ * An entry point supplies what it alone knows: how a call it produced is
+ * scheduled. Everything else - which interfaces are usable, what a method may
+ * return, and turning a call on the interface into a {@link JsInvokerCall} - is
+ * the same everywhere and lives here.
+ *
+ * For internal use only. May be renamed or removed in a future release.
+ */
+public final class JsInvokers implements Serializable {
+
+ private JsInvokers() {
+ }
+
+ /**
+ * Creates an invoker for the given interface, scheduling every call it
+ * receives with the given scheduler.
+ *
+ * @param
+ * the invoker interface type
+ * @param invokerType
+ * the invoker interface, annotated with {@link JsInvoker}, not
+ * null
+ * @param scheduler
+ * what sends a call to the client, not null
+ * @return an invoker for the interface, not null
+ * @throws IllegalArgumentException
+ * if the type is not an interface, or is not annotated with
+ * {@link JsInvoker} and therefore has no JavaScript in the
+ * bundle
+ */
+ @SuppressWarnings("unchecked")
+ public static T create(Class invokerType,
+ SerializableFunction scheduler) {
+ Objects.requireNonNull(invokerType, "Invoker type cannot be null");
+ Objects.requireNonNull(scheduler, "Scheduler cannot be null");
+ if (!invokerType.isInterface()) {
+ throw new IllegalArgumentException(
+ invokerType.getName() + " is not an interface");
+ }
+ if (!invokerType.isAnnotationPresent(JsInvoker.class)) {
+ throw new IllegalArgumentException(invokerType.getName()
+ + " is not annotated with @JsInvoker, so the build does not"
+ + " collect its JavaScript into the bundle");
+ }
+ return (T) Proxy.newProxyInstance(invokerType.getClassLoader(),
+ new Class>[] { invokerType },
+ new JsInvokerHandler(invokerType, scheduler));
+ }
+
+ /**
+ * Turns a call on an invoker interface into a scheduled invocation that
+ * carries the call.
+ */
+ private record JsInvokerHandler(Class> invokerType,
+ SerializableFunction scheduler)
+ implements
+ InvocationHandler,
+ Serializable {
+
+ @Override
+ public Object invoke(Object proxy, Method method, Object[] args)
+ throws Throwable {
+ if (method.getDeclaringClass() == Object.class) {
+ return method.invoke(this, args);
+ }
+ Class> returnType = method.getReturnType();
+ boolean returnsResult = returnType
+ .isAssignableFrom(PendingJavaScriptResult.class);
+ // Checked before scheduling, so that a method the invoker can not
+ // answer does not run in the browser either
+ if (returnType != void.class && !returnsResult) {
+ throw new IllegalStateException("Method " + method.getName()
+ + " of " + invokerType.getName()
+ + " must return void or PendingJavaScriptResult");
+ }
+ List arguments = args == null ? List.of()
+ : Arrays.asList(args);
+ PendingJavaScriptResult result = scheduler.apply(new JsInvokerCall(
+ invokerType, method.getName(), arguments));
+ return returnsResult ? result : null;
+ }
+ }
+}
diff --git a/flow-server/src/main/java/com/vaadin/flow/server/communication/UidlWriter.java b/flow-server/src/main/java/com/vaadin/flow/server/communication/UidlWriter.java
index 3eccdd020f1..c7deefe296f 100644
--- a/flow-server/src/main/java/com/vaadin/flow/server/communication/UidlWriter.java
+++ b/flow-server/src/main/java/com/vaadin/flow/server/communication/UidlWriter.java
@@ -394,8 +394,10 @@ private static ArrayNode encodeExecuteJavaScript(
*
* The target tells the client how to read the parameters: the first
* arguments of them are the arguments of the call, the next
- * one is the element to apply the function to, and the two after that are
- * the return value channels when returns is set.
+ * one is the element to apply the function to when element is
+ * set, and the two after that are the return value channels when
+ * returns is set. Without element the function
+ * runs with no this, which is what a page invoker does.
*/
private static ArrayNode encodeInvokerCall(
PendingJavaScriptInvocation invocation, JsInvokerCall call) {
@@ -407,6 +409,12 @@ private static ArrayNode encodeInvokerCall(
target.put(JsonConstants.UIDL_KEY_INVOKER_METHOD, call.getMethodId());
target.put(JsonConstants.UIDL_KEY_INVOKER_ARGUMENTS,
call.arguments().size());
+ // An element invoker appends the element it is bound to after the
+ // arguments, and a page invoker has nothing to append
+ if (invocation.getInvocation().getParameters().size() > call.arguments()
+ .size()) {
+ target.put(JsonConstants.UIDL_KEY_INVOKER_ELEMENT, true);
+ }
if (invocation.isSubscribed()) {
StateNode owner = invocation.getOwner();
diff --git a/flow-server/src/main/java/com/vaadin/flow/shared/JsonConstants.java b/flow-server/src/main/java/com/vaadin/flow/shared/JsonConstants.java
index 99c0d489fb3..e1e2e2cbcdb 100644
--- a/flow-server/src/main/java/com/vaadin/flow/shared/JsonConstants.java
+++ b/flow-server/src/main/java/com/vaadin/flow/shared/JsonConstants.java
@@ -190,6 +190,13 @@ public class JsonConstants implements Serializable {
*/
public static final String UIDL_KEY_INVOKER_RETURNS = "returns";
+ /**
+ * Key that marks a JS invoker invocation whose parameter after the
+ * arguments is the element to apply the function to. An invocation without
+ * it runs the function with no this.
+ */
+ public static final String UIDL_KEY_INVOKER_ELEMENT = "element";
+
/**
* Key used to hold the feature id when synchronizing node values.
*/
diff --git a/flow-server/src/test/java/com/vaadin/flow/component/page/PageTest.java b/flow-server/src/test/java/com/vaadin/flow/component/page/PageTest.java
index 3328d24e6ea..f02258b623f 100644
--- a/flow-server/src/test/java/com/vaadin/flow/component/page/PageTest.java
+++ b/flow-server/src/test/java/com/vaadin/flow/component/page/PageTest.java
@@ -15,6 +15,7 @@
*/
package com.vaadin.flow.component.page;
+import java.io.Serializable;
import java.net.URL;
import java.util.ArrayList;
import java.util.Arrays;
@@ -32,8 +33,13 @@
import tools.jackson.databind.JsonNode;
import com.vaadin.flow.component.UI;
+import com.vaadin.flow.component.internal.PendingJavaScriptInvocation;
+import com.vaadin.flow.component.internal.UIInternals.JavaScriptInvocation;
import com.vaadin.flow.function.SerializableConsumer;
import com.vaadin.flow.internal.JacksonUtils;
+import com.vaadin.flow.js.JsExpression;
+import com.vaadin.flow.js.JsInvoker;
+import com.vaadin.flow.js.JsInvokerCall;
import com.vaadin.flow.server.InitParameters;
import com.vaadin.flow.server.VaadinService;
import com.vaadin.flow.server.VaadinSession;
@@ -86,6 +92,31 @@ public PendingJavaScriptResult executeJs(String expression,
private BrowserWindowResizeListener listener = event -> {
};
+ @JsInvoker
+ interface PageJs extends Serializable {
+ @JsExpression("window.alert($0)")
+ void showGreeting(String greeting);
+ }
+
+ @Test
+ void getJsInvoker_schedulesTheCallWithoutAnElement() {
+ MockUI mockUI = new MockUI();
+
+ mockUI.getPage().getJsInvoker(PageJs.class).showGreeting("Hello");
+
+ List invocations = mockUI.getInternals()
+ .dumpPendingJavaScriptInvocations();
+ assertEquals(1, invocations.size());
+ JavaScriptInvocation invocation = invocations.get(0).getInvocation();
+
+ assertEquals(new JsInvokerCall(PageJs.class, "showGreeting",
+ List.of("Hello")), invocation.getInvokerCall());
+ assertEquals(List.of("Hello"), invocation.getParameters(),
+ "a page invoker has no element to apply the function to");
+ assertEquals("window.alert($0)", invocation.getExpression(),
+ "the declared expression should not be wrapped");
+ }
+
@Test
void addNullAsAListener_trows() {
diff --git a/flow-server/src/test/java/com/vaadin/flow/server/communication/UidlWriterTest.java b/flow-server/src/test/java/com/vaadin/flow/server/communication/UidlWriterTest.java
index d31e5b719ce..ac3c34dfd7a 100644
--- a/flow-server/src/test/java/com/vaadin/flow/server/communication/UidlWriterTest.java
+++ b/flow-server/src/test/java/com/vaadin/flow/server/communication/UidlWriterTest.java
@@ -222,6 +222,7 @@ void encodeExecuteJavaScript_invokerCall_sendsTheTargetInsteadOfTheScript() {
target.put("invoker", TestJs.class.getName());
target.put("method", "method/1");
target.put("arguments", 1);
+ target.put("element", true);
ArrayNode expectedJson = JacksonUtils.createArray(
JacksonUtils.createArray(JacksonUtils.createNode("foo"),
// Null since element is not attached
@@ -258,6 +259,29 @@ void encodeExecuteJavaScript_subscribedInvokerCall_addsTheReturnChannels() {
assertEquals(1, target.get("arguments").asInt());
}
+ @Test
+ void encodeExecuteJavaScript_invokerCallWithoutAnElement_targetSaysSo() {
+ Element element = ElementFactory.createDiv();
+
+ // What a page invoker schedules: the arguments and nothing else, since
+ // there is no element to apply the function to
+ JsInvokerCall call = new JsInvokerCall(TestJs.class, "method",
+ List.of("foo"));
+ JavaScriptInvocation invocation = new JavaScriptInvocation(call,
+ call.getExpression(), "foo");
+
+ ArrayNode json = UidlWriter.encodeExecuteJavaScriptList(
+ List.of(new PendingJavaScriptInvocation(element.getNode(),
+ invocation)));
+
+ ArrayNode encoded = (ArrayNode) json.get(0);
+ assertEquals(2, encoded.size(),
+ "the argument should be followed by the target alone: "
+ + encoded);
+ assertFalse(((ObjectNode) encoded.get(1)).has("element"),
+ "without an element the function runs with no this");
+ }
+
@JsInvoker
interface TestJs extends Serializable {
@JsExpression("this.method($0)")
From 1e9545ddcd8f5cd65ce36449c32a07313c7812b9 Mon Sep 17 00:00:00 2001
From: "totally-not-ai[bot]"
<290682512+totally-not-ai[bot]@users.noreply.github.com>
Date: Fri, 18 Sep 2026 11:33:05 +0000
Subject: [PATCH 15/57] refactor: fold the page invoker into how the page
already schedules
Both paths build their invocation and hand it to one place that owns it
with the root node, the way the element side funnels its two paths
through one scheduler. The marker that ties the expression javadoc to its
element counterpart sits with that method again, and the call type says
it can come from either invoker.
The combination the client's channel indexing depends on is covered too:
a call with no element that is subscribed to, where the channels follow
the arguments with nothing in between, and a page invoker method that
answers with the execution.
---
.../com/vaadin/flow/component/page/Page.java | 25 +++++++++---------
.../com/vaadin/flow/js/JsInvokerCall.java | 5 ++--
.../vaadin/flow/component/page/PageTest.java | 25 ++++++++++++++++++
.../server/communication/UidlWriterTest.java | 26 +++++++++++++++++++
4 files changed, 67 insertions(+), 14 deletions(-)
diff --git a/flow-server/src/main/java/com/vaadin/flow/component/page/Page.java b/flow-server/src/main/java/com/vaadin/flow/component/page/Page.java
index 08a2ab2b3fb..d480b90116c 100644
--- a/flow-server/src/main/java/com/vaadin/flow/component/page/Page.java
+++ b/flow-server/src/main/java/com/vaadin/flow/component/page/Page.java
@@ -319,7 +319,6 @@ public void addDynamicImport(String expression) {
addDependency(new Dependency(Type.DYNAMIC_IMPORT, expression));
}
- // When updating JavaDocs here, keep in sync with Element.executeJavaScript
/**
* Gets an invoker for the JavaScript that the given interface declares, for
* this page.
@@ -373,15 +372,25 @@ public T getJsInvoker(Class invokerType) {
* function to.
*/
private PendingJavaScriptResult scheduleInvokerCall(JsInvokerCall call) {
- JavaScriptInvocation invocation = new JavaScriptInvocation(call,
- call.getExpression(), call.arguments().toArray());
+ return schedule(new JavaScriptInvocation(call, call.getExpression(),
+ call.arguments().toArray()));
+ }
+ /**
+ * Queues an invocation for the client, owned by the root node of the state
+ * tree, which is what makes it an invocation of this page rather than of
+ * anything in it.
+ */
+ private PendingJavaScriptResult schedule(JavaScriptInvocation invocation) {
PendingJavaScriptInvocation execution = new PendingJavaScriptInvocation(
ui.getInternals().getStateTree().getRootNode(), invocation);
+
ui.getInternals().addJavaScriptInvocation(execution);
+
return execution;
}
+ // When updating JavaDocs here, keep in sync with Element.executeJavaScript
/**
* Asynchronously runs the given JavaScript expression in the browser.
*
@@ -429,15 +438,7 @@ private PendingJavaScriptResult scheduleInvokerCall(JsInvokerCall call) {
*/
public PendingJavaScriptResult executeJs(String expression,
Object... parameters) {
- JavaScriptInvocation invocation = new JavaScriptInvocation(expression,
- parameters);
-
- PendingJavaScriptInvocation execution = new PendingJavaScriptInvocation(
- ui.getInternals().getStateTree().getRootNode(), invocation);
-
- ui.getInternals().addJavaScriptInvocation(execution);
-
- return execution;
+ return schedule(new JavaScriptInvocation(expression, parameters));
}
/**
diff --git a/flow-server/src/main/java/com/vaadin/flow/js/JsInvokerCall.java b/flow-server/src/main/java/com/vaadin/flow/js/JsInvokerCall.java
index ffe3f010dd6..95e47446bb2 100644
--- a/flow-server/src/main/java/com/vaadin/flow/js/JsInvokerCall.java
+++ b/flow-server/src/main/java/com/vaadin/flow/js/JsInvokerCall.java
@@ -27,8 +27,9 @@
import com.vaadin.flow.dom.Element;
/**
- * A call made through {@link Element#getJsInvoker(Class)}: which invoker
- * interface, which method of it, and the arguments that were passed.
+ * A call made through an invoker - {@link Element#getJsInvoker(Class)} or
+ * {@link com.vaadin.flow.component.page.Page#getJsInvoker(Class)}: which
+ * invoker interface, which method of it, and the arguments that were passed.
*
* The call is what the client receives — the interface, the method and the
* arguments, never the JavaScript itself, which the client looks up in the
diff --git a/flow-server/src/test/java/com/vaadin/flow/component/page/PageTest.java b/flow-server/src/test/java/com/vaadin/flow/component/page/PageTest.java
index f02258b623f..e3f1c44a266 100644
--- a/flow-server/src/test/java/com/vaadin/flow/component/page/PageTest.java
+++ b/flow-server/src/test/java/com/vaadin/flow/component/page/PageTest.java
@@ -50,6 +50,7 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
@@ -96,6 +97,30 @@ public PendingJavaScriptResult executeJs(String expression,
interface PageJs extends Serializable {
@JsExpression("window.alert($0)")
void showGreeting(String greeting);
+
+ @JsExpression("return navigator.clipboard.readText()")
+ PendingJavaScriptResult readText();
+ }
+
+ @Test
+ void getJsInvoker_methodReturningAResult_answersWithTheExecution() {
+ MockUI mockUI = new MockUI();
+
+ PendingJavaScriptResult result = mockUI.getPage()
+ .getJsInvoker(PageJs.class).readText();
+ List values = new ArrayList<>();
+ result.then(String.class, values::add);
+
+ List invocations = mockUI.getInternals()
+ .dumpPendingJavaScriptInvocations();
+ assertEquals(1, invocations.size());
+ assertSame(result, invocations.get(0),
+ "the pending execution is what the method answers with");
+ assertTrue(invocations.get(0).isSubscribed(),
+ "the return value should be asked for from the client");
+
+ invocations.get(0).complete(JacksonUtils.createNode("text"));
+ assertEquals(List.of("text"), values);
}
@Test
diff --git a/flow-server/src/test/java/com/vaadin/flow/server/communication/UidlWriterTest.java b/flow-server/src/test/java/com/vaadin/flow/server/communication/UidlWriterTest.java
index ac3c34dfd7a..bf3eebdaa20 100644
--- a/flow-server/src/test/java/com/vaadin/flow/server/communication/UidlWriterTest.java
+++ b/flow-server/src/test/java/com/vaadin/flow/server/communication/UidlWriterTest.java
@@ -282,6 +282,32 @@ void encodeExecuteJavaScript_invokerCallWithoutAnElement_targetSaysSo() {
"without an element the function runs with no this");
}
+ @Test
+ void encodeExecuteJavaScript_subscribedInvokerCallWithoutAnElement_channelsFollowTheArguments() {
+ Element element = ElementFactory.createDiv();
+
+ JsInvokerCall call = new JsInvokerCall(TestJs.class, "method",
+ List.of("foo"));
+ JavaScriptInvocation invocation = new JavaScriptInvocation(call,
+ call.getExpression(), "foo");
+ PendingJavaScriptInvocation pending = new PendingJavaScriptInvocation(
+ element.getNode(), invocation);
+ pending.then(value -> {
+ });
+
+ ArrayNode json = UidlWriter
+ .encodeExecuteJavaScriptList(List.of(pending));
+
+ ArrayNode encoded = (ArrayNode) json.get(0);
+ assertEquals(4, encoded.size(),
+ "the argument should be followed by the two channels and the target: "
+ + encoded);
+ ObjectNode target = (ObjectNode) encoded.get(3);
+ assertTrue(target.get("returns").asBoolean());
+ assertFalse(target.has("element"),
+ "the channels follow the arguments when there is no element");
+ }
+
@JsInvoker
interface TestJs extends Serializable {
@JsExpression("this.method($0)")
From 930b6e006714d0f3b2873f23704f1d40654cebc6 Mon Sep 17 00:00:00 2001
From: "totally-not-ai[bot]"
<290682512+totally-not-ai[bot]@users.noreply.github.com>
Date: Fri, 18 Sep 2026 11:38:21 +0000
Subject: [PATCH 16/57] Revert "feat: invoke page level JavaScript through an
invoker as well"
Page level invocations are their own change on top of this one, so they
move to a branch of their own and this one stays about the element level
entry point and the machinery both of them use.
This reverts commit dce444d847 and its follow-up 1e9545ddcd.
---
.../client/flow/ExecuteJavaScriptProcessor.ts | 29 ++---
.../flow/ExecuteJavaScriptProcessorTests.ts | 43 +------
.../com/vaadin/flow/component/page/Page.java | 85 ++-----------
.../java/com/vaadin/flow/dom/Element.java | 51 +++++++-
.../com/vaadin/flow/js/JsInvokerCall.java | 5 +-
.../java/com/vaadin/flow/js/JsInvokers.java | 114 ------------------
.../flow/server/communication/UidlWriter.java | 12 +-
.../com/vaadin/flow/shared/JsonConstants.java | 7 --
.../vaadin/flow/component/page/PageTest.java | 56 ---------
.../server/communication/UidlWriterTest.java | 50 --------
10 files changed, 80 insertions(+), 372 deletions(-)
delete mode 100644 flow-server/src/main/java/com/vaadin/flow/js/JsInvokers.java
diff --git a/flow-client/src/main/frontend/internal/client/flow/ExecuteJavaScriptProcessor.ts b/flow-client/src/main/frontend/internal/client/flow/ExecuteJavaScriptProcessor.ts
index d01ad968234..4def3338a4c 100644
--- a/flow-client/src/main/frontend/internal/client/flow/ExecuteJavaScriptProcessor.ts
+++ b/flow-client/src/main/frontend/internal/client/flow/ExecuteJavaScriptProcessor.ts
@@ -76,7 +76,6 @@ export interface JsInvokerTarget {
invoker: string;
method: string;
arguments: number;
- element?: boolean;
returns?: boolean;
}
@@ -247,20 +246,19 @@ export class ExecuteJavaScriptProcessor {
*
* @param target - the invoker interface and method to run
* @param parameters - the decoded parameters: the arguments of the call, the
- * element to apply the function to when the target has one, and the
- * return value channels when the target declares them
+ * element to apply the function to, and the return value channels
+ * when the target declares them
*/
protected invokeFromBundle(target: JsInvokerTarget, parameters: unknown[]): void {
const argumentCount = target.arguments;
- const hasElement = target.element === true;
// The parameters are the arguments of the call, then the element to apply
- // the function to when the target has one, then the two return value
- // channels when the target declares them. Nothing else may be in there, so
- // a count that does not add up means the invocation was not built by the
- // server this client talks to, and reading the element out of it by index
- // would bind an argument as `this`. Say so instead of running the call.
- const expectedCount = argumentCount + (hasElement ? 1 : 0) + (target.returns === true ? 2 : 0);
+ // the function to, then the two return value channels when the target
+ // declares them. Nothing else may be in there, so a count that does not
+ // add up means the invocation was not built by the server this client
+ // talks to, and reading the element out of it by index would bind an
+ // argument as `this`. Say so instead of running the call.
+ const expectedCount = argumentCount + 1 + (target.returns === true ? 2 : 0);
if (parameters.length !== expectedCount) {
const message = `Expected ${expectedCount} parameters for ${target.invoker}.${target.method} but the invocation carries ${parameters.length}. Reload the page to pick up the current signature.`;
Console.error(message);
@@ -277,9 +275,8 @@ export class ExecuteJavaScriptProcessor {
return;
}
- const channelIndex = argumentCount + (hasElement ? 1 : 0);
- const onSuccess = target.returns === true ? (parameters[channelIndex] as ReturnChannel) : undefined;
- const onError = target.returns === true ? (parameters[channelIndex + 1] as ReturnChannel) : undefined;
+ const onSuccess = target.returns === true ? (parameters[argumentCount + 1] as ReturnChannel) : undefined;
+ const onError = target.returns === true ? (parameters[argumentCount + 2] as ReturnChannel) : undefined;
const fn = findInvokerFunction(target.invoker, target.method);
if (fn === undefined) {
@@ -290,10 +287,8 @@ export class ExecuteJavaScriptProcessor {
}
// The element the invoker was obtained from is the parameter after the
- // arguments, and it is what the function runs against. A page invoker has
- // no element, and its JavaScript works on globals rather than on a
- // `this`.
- const thisArg = hasElement ? parameters[argumentCount] : undefined;
+ // arguments, and it is what the function runs against.
+ const thisArg = parameters[argumentCount];
try {
const result = fn.apply(thisArg, parameters.slice(0, argumentCount));
if (onSuccess !== undefined) {
diff --git a/flow-client/src/test/frontend/internal/client/flow/ExecuteJavaScriptProcessorTests.ts b/flow-client/src/test/frontend/internal/client/flow/ExecuteJavaScriptProcessorTests.ts
index b7b2152da46..9b52e16f903 100644
--- a/flow-client/src/test/frontend/internal/client/flow/ExecuteJavaScriptProcessorTests.ts
+++ b/flow-client/src/test/frontend/internal/client/flow/ExecuteJavaScriptProcessorTests.ts
@@ -107,9 +107,7 @@ describe('ExecuteJavaScriptProcessor', () => {
});
const element = { tagName: 'div' };
- processor().execute([
- ['Hello', element, { invoker: INVOKER, method: 'showGreeting/1', arguments: 1, element: true }]
- ]);
+ processor().execute([['Hello', element, { invoker: INVOKER, method: 'showGreeting/1', arguments: 1 }]]);
expect(calls).to.have.lengthOf(1);
expect(calls[0].thisArg).to.equal(element);
@@ -126,7 +124,7 @@ describe('ExecuteJavaScriptProcessor', () => {
element,
(value: unknown) => resolved.push(value),
() => {},
- { invoker: INVOKER, method: 'readValue/0', arguments: 0, element: true, returns: true }
+ { invoker: INVOKER, method: 'readValue/0', arguments: 0, returns: true }
]
]);
// Settled in microtasks: a macrotask wait would also pick up the
@@ -145,7 +143,7 @@ describe('ExecuteJavaScriptProcessor', () => {
// One argument declared, but no element to apply the function to: the
// invocation and this client disagree about the signature.
- processor().execute([['Hello', { invoker: INVOKER, method: 'showGreeting/1', arguments: 1, element: true }]]);
+ processor().execute([['Hello', { invoker: INVOKER, method: 'showGreeting/1', arguments: 1 }]]);
expect(calls).to.equal(0);
});
@@ -164,7 +162,7 @@ describe('ExecuteJavaScriptProcessor', () => {
[
element,
(error: unknown) => errors.push(error),
- { invoker: INVOKER, method: 'readValue/0', arguments: 0, element: true, returns: true }
+ { invoker: INVOKER, method: 'readValue/0', arguments: 0, returns: true }
]
]);
@@ -181,41 +179,12 @@ describe('ExecuteJavaScriptProcessor', () => {
});
processor().execute([
- [
- 'Hello',
- 'unexpected',
- { tagName: 'div' },
- { invoker: INVOKER, method: 'showGreeting/1', arguments: 1, element: true }
- ]
+ ['Hello', 'unexpected', { tagName: 'div' }, { invoker: INVOKER, method: 'showGreeting/1', arguments: 1 }]
]);
expect(calls).to.equal(0);
});
- it('runs a call with no element without a this, and answers its channel', async () => {
- // What a page invoker sends: the arguments, then the channels, and no
- // element to apply the function to.
- const thisArgs: unknown[] = [];
- registerInvoker('readValue/0', function (this: unknown) {
- thisArgs.push(this);
- return 'answer';
- });
- const resolved: unknown[] = [];
-
- processor().execute([
- [
- (value: unknown) => resolved.push(value),
- () => {},
- { invoker: INVOKER, method: 'readValue/0', arguments: 0, returns: true }
- ]
- ]);
- await Promise.resolve();
- await Promise.resolve();
-
- expect(thisArgs).to.eql([undefined]);
- expect(resolved).to.eql(['answer']);
- });
-
it('reports a function that is not in the bundle to the error channel', () => {
const errors: unknown[] = [];
const element = { tagName: 'div' };
@@ -225,7 +194,7 @@ describe('ExecuteJavaScriptProcessor', () => {
element,
() => {},
(error: unknown) => errors.push(error),
- { invoker: INVOKER, method: 'missing/0', arguments: 0, element: true, returns: true }
+ { invoker: INVOKER, method: 'missing/0', arguments: 0, returns: true }
]
]);
diff --git a/flow-server/src/main/java/com/vaadin/flow/component/page/Page.java b/flow-server/src/main/java/com/vaadin/flow/component/page/Page.java
index d480b90116c..849d9a90981 100644
--- a/flow-server/src/main/java/com/vaadin/flow/component/page/Page.java
+++ b/flow-server/src/main/java/com/vaadin/flow/component/page/Page.java
@@ -40,10 +40,6 @@
import com.vaadin.flow.dom.JsFunction;
import com.vaadin.flow.function.SerializableConsumer;
import com.vaadin.flow.internal.UrlUtil;
-import com.vaadin.flow.js.JsExpression;
-import com.vaadin.flow.js.JsInvoker;
-import com.vaadin.flow.js.JsInvokerCall;
-import com.vaadin.flow.js.JsInvokers;
import com.vaadin.flow.server.InitParameters;
import com.vaadin.flow.shared.Registration;
import com.vaadin.flow.shared.ui.Dependency;
@@ -319,77 +315,6 @@ public void addDynamicImport(String expression) {
addDependency(new Dependency(Type.DYNAMIC_IMPORT, expression));
}
- /**
- * Gets an invoker for the JavaScript that the given interface declares, for
- * this page.
- *
- * The interface is annotated with {@link JsInvoker} and each of its methods
- * declares the JavaScript it runs with {@link JsExpression}. Calling a
- * method runs that JavaScript in the browser with the method arguments as
- * its parameters:
- *
- *
- * @JsInvoker
- * public interface ClipboardJs extends Serializable {
- * @JsExpression("return navigator.clipboard.readText()")
- * PendingJavaScriptResult readText();
- * }
- *
- * page.getJsInvoker(ClipboardJs.class).readText().then(String.class,
- * text -> ...);
- *
- *
- * Unlike {@link #executeJs(String, Object...)}, nothing about the
- * JavaScript is decided at the call site: the build collects the
- * declarations of every invoker interface into the bundle, and the client
- * runs the collected function after looking it up by interface and method.
- * No expression is sent and none is compiled in the browser, so the call
- * works under a content security policy without unsafe-eval.
- *
- * A method of a page invoker runs without a this, so its
- * JavaScript works on globals - which is what page-level JavaScript does
- * anyway. Use {@link Element#getJsInvoker(Class)} for JavaScript that acts
- * on an element.
- *
- * A method returns either void or
- * {@link PendingJavaScriptResult}.
- *
- * @param
- * the invoker interface type
- * @param invokerType
- * the invoker interface, not null
- * @return an invoker for this page, not null
- */
- public T getJsInvoker(Class invokerType) {
- return JsInvokers.create(invokerType, this::scheduleInvokerCall);
- }
-
- /**
- * Schedules a call made through a page invoker, the way
- * {@link #executeJs(String, Object...)} schedules an expression, so the two
- * reach the client in the order they were made. The parameters are the
- * arguments of the call and nothing else: there is no element to apply the
- * function to.
- */
- private PendingJavaScriptResult scheduleInvokerCall(JsInvokerCall call) {
- return schedule(new JavaScriptInvocation(call, call.getExpression(),
- call.arguments().toArray()));
- }
-
- /**
- * Queues an invocation for the client, owned by the root node of the state
- * tree, which is what makes it an invocation of this page rather than of
- * anything in it.
- */
- private PendingJavaScriptResult schedule(JavaScriptInvocation invocation) {
- PendingJavaScriptInvocation execution = new PendingJavaScriptInvocation(
- ui.getInternals().getStateTree().getRootNode(), invocation);
-
- ui.getInternals().addJavaScriptInvocation(execution);
-
- return execution;
- }
-
// When updating JavaDocs here, keep in sync with Element.executeJavaScript
/**
* Asynchronously runs the given JavaScript expression in the browser.
@@ -438,7 +363,15 @@ private PendingJavaScriptResult schedule(JavaScriptInvocation invocation) {
*/
public PendingJavaScriptResult executeJs(String expression,
Object... parameters) {
- return schedule(new JavaScriptInvocation(expression, parameters));
+ JavaScriptInvocation invocation = new JavaScriptInvocation(expression,
+ parameters);
+
+ PendingJavaScriptInvocation execution = new PendingJavaScriptInvocation(
+ ui.getInternals().getStateTree().getRootNode(), invocation);
+
+ ui.getInternals().addJavaScriptInvocation(execution);
+
+ return execution;
}
/**
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 c6aa8bd2993..9d89020b1d0 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
@@ -16,6 +16,9 @@
package com.vaadin.flow.dom;
import java.io.Serializable;
+import java.lang.reflect.InvocationHandler;
+import java.lang.reflect.Method;
+import java.lang.reflect.Proxy;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
@@ -67,7 +70,6 @@
import com.vaadin.flow.js.JsExpression;
import com.vaadin.flow.js.JsInvoker;
import com.vaadin.flow.js.JsInvokerCall;
-import com.vaadin.flow.js.JsInvokers;
import com.vaadin.flow.server.AbstractStreamResource;
import com.vaadin.flow.server.Command;
import com.vaadin.flow.server.StreamResource;
@@ -1975,8 +1977,53 @@ public PendingJavaScriptResult executeJs(String expression,
* the invoker interface, not null
* @return an invoker bound to this element, not null
*/
+ @SuppressWarnings("unchecked")
public T getJsInvoker(Class invokerType) {
- return JsInvokers.create(invokerType, this::scheduleInvokerCall);
+ Objects.requireNonNull(invokerType, "Invoker type cannot be null");
+ if (!invokerType.isInterface()) {
+ throw new IllegalArgumentException(
+ invokerType.getName() + " is not an interface");
+ }
+ if (!invokerType.isAnnotationPresent(JsInvoker.class)) {
+ throw new IllegalArgumentException(invokerType.getName()
+ + " is not annotated with @JsInvoker, so the build does not"
+ + " collect its JavaScript into the bundle");
+ }
+ return (T) Proxy.newProxyInstance(invokerType.getClassLoader(),
+ new Class>[] { invokerType },
+ new JsInvokerHandler(this, invokerType));
+ }
+
+ /**
+ * Turns a call on a JS invoker interface into a scheduled invocation that
+ * carries the call.
+ */
+ private record JsInvokerHandler(Element element,
+ Class> invokerType) implements InvocationHandler, Serializable {
+
+ @Override
+ public Object invoke(Object proxy, Method method, Object[] args)
+ throws Throwable {
+ if (method.getDeclaringClass() == Object.class) {
+ return method.invoke(this, args);
+ }
+ Class> returnType = method.getReturnType();
+ boolean returnsResult = returnType
+ .isAssignableFrom(PendingJavaScriptResult.class);
+ // Checked before scheduling, so that a method the invoker can not
+ // answer does not run in the browser either
+ if (returnType != void.class && !returnsResult) {
+ throw new IllegalStateException("Method " + method.getName()
+ + " of " + invokerType.getName()
+ + " must return void or PendingJavaScriptResult");
+ }
+ List arguments = args == null ? List.of()
+ : Arrays.asList(args);
+ PendingJavaScriptResult result = element
+ .scheduleInvokerCall(new JsInvokerCall(invokerType,
+ method.getName(), arguments));
+ return returnsResult ? result : null;
+ }
}
private PendingJavaScriptResult scheduleExecuteJs(String expression,
diff --git a/flow-server/src/main/java/com/vaadin/flow/js/JsInvokerCall.java b/flow-server/src/main/java/com/vaadin/flow/js/JsInvokerCall.java
index 95e47446bb2..ffe3f010dd6 100644
--- a/flow-server/src/main/java/com/vaadin/flow/js/JsInvokerCall.java
+++ b/flow-server/src/main/java/com/vaadin/flow/js/JsInvokerCall.java
@@ -27,9 +27,8 @@
import com.vaadin.flow.dom.Element;
/**
- * A call made through an invoker - {@link Element#getJsInvoker(Class)} or
- * {@link com.vaadin.flow.component.page.Page#getJsInvoker(Class)}: which
- * invoker interface, which method of it, and the arguments that were passed.
+ * A call made through {@link Element#getJsInvoker(Class)}: which invoker
+ * interface, which method of it, and the arguments that were passed.
*
* The call is what the client receives — the interface, the method and the
* arguments, never the JavaScript itself, which the client looks up in the
diff --git a/flow-server/src/main/java/com/vaadin/flow/js/JsInvokers.java b/flow-server/src/main/java/com/vaadin/flow/js/JsInvokers.java
deleted file mode 100644
index b8ce19be215..00000000000
--- a/flow-server/src/main/java/com/vaadin/flow/js/JsInvokers.java
+++ /dev/null
@@ -1,114 +0,0 @@
-/*
- * 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.js;
-
-import java.io.Serializable;
-import java.lang.reflect.InvocationHandler;
-import java.lang.reflect.Method;
-import java.lang.reflect.Proxy;
-import java.util.Arrays;
-import java.util.List;
-import java.util.Objects;
-
-import com.vaadin.flow.component.page.PendingJavaScriptResult;
-import com.vaadin.flow.function.SerializableFunction;
-
-/**
- * Creates the invokers that the entry points hand out, so that an invoker
- * behaves the same whichever of them it came from.
- *
- * An entry point supplies what it alone knows: how a call it produced is
- * scheduled. Everything else - which interfaces are usable, what a method may
- * return, and turning a call on the interface into a {@link JsInvokerCall} - is
- * the same everywhere and lives here.
- *
- * For internal use only. May be renamed or removed in a future release.
- */
-public final class JsInvokers implements Serializable {
-
- private JsInvokers() {
- }
-
- /**
- * Creates an invoker for the given interface, scheduling every call it
- * receives with the given scheduler.
- *
- * @param
- * the invoker interface type
- * @param invokerType
- * the invoker interface, annotated with {@link JsInvoker}, not
- * null
- * @param scheduler
- * what sends a call to the client, not null
- * @return an invoker for the interface, not null
- * @throws IllegalArgumentException
- * if the type is not an interface, or is not annotated with
- * {@link JsInvoker} and therefore has no JavaScript in the
- * bundle
- */
- @SuppressWarnings("unchecked")
- public static T create(Class invokerType,
- SerializableFunction scheduler) {
- Objects.requireNonNull(invokerType, "Invoker type cannot be null");
- Objects.requireNonNull(scheduler, "Scheduler cannot be null");
- if (!invokerType.isInterface()) {
- throw new IllegalArgumentException(
- invokerType.getName() + " is not an interface");
- }
- if (!invokerType.isAnnotationPresent(JsInvoker.class)) {
- throw new IllegalArgumentException(invokerType.getName()
- + " is not annotated with @JsInvoker, so the build does not"
- + " collect its JavaScript into the bundle");
- }
- return (T) Proxy.newProxyInstance(invokerType.getClassLoader(),
- new Class>[] { invokerType },
- new JsInvokerHandler(invokerType, scheduler));
- }
-
- /**
- * Turns a call on an invoker interface into a scheduled invocation that
- * carries the call.
- */
- private record JsInvokerHandler(Class> invokerType,
- SerializableFunction scheduler)
- implements
- InvocationHandler,
- Serializable {
-
- @Override
- public Object invoke(Object proxy, Method method, Object[] args)
- throws Throwable {
- if (method.getDeclaringClass() == Object.class) {
- return method.invoke(this, args);
- }
- Class> returnType = method.getReturnType();
- boolean returnsResult = returnType
- .isAssignableFrom(PendingJavaScriptResult.class);
- // Checked before scheduling, so that a method the invoker can not
- // answer does not run in the browser either
- if (returnType != void.class && !returnsResult) {
- throw new IllegalStateException("Method " + method.getName()
- + " of " + invokerType.getName()
- + " must return void or PendingJavaScriptResult");
- }
- List arguments = args == null ? List.of()
- : Arrays.asList(args);
- PendingJavaScriptResult result = scheduler.apply(new JsInvokerCall(
- invokerType, method.getName(), arguments));
- return returnsResult ? result : null;
- }
- }
-}
diff --git a/flow-server/src/main/java/com/vaadin/flow/server/communication/UidlWriter.java b/flow-server/src/main/java/com/vaadin/flow/server/communication/UidlWriter.java
index c7deefe296f..3eccdd020f1 100644
--- a/flow-server/src/main/java/com/vaadin/flow/server/communication/UidlWriter.java
+++ b/flow-server/src/main/java/com/vaadin/flow/server/communication/UidlWriter.java
@@ -394,10 +394,8 @@ private static ArrayNode encodeExecuteJavaScript(
*
* The target tells the client how to read the parameters: the first
* arguments of them are the arguments of the call, the next
- * one is the element to apply the function to when element is
- * set, and the two after that are the return value channels when
- * returns is set. Without element the function
- * runs with no this, which is what a page invoker does.
+ * one is the element to apply the function to, and the two after that are
+ * the return value channels when returns is set.
*/
private static ArrayNode encodeInvokerCall(
PendingJavaScriptInvocation invocation, JsInvokerCall call) {
@@ -409,12 +407,6 @@ private static ArrayNode encodeInvokerCall(
target.put(JsonConstants.UIDL_KEY_INVOKER_METHOD, call.getMethodId());
target.put(JsonConstants.UIDL_KEY_INVOKER_ARGUMENTS,
call.arguments().size());
- // An element invoker appends the element it is bound to after the
- // arguments, and a page invoker has nothing to append
- if (invocation.getInvocation().getParameters().size() > call.arguments()
- .size()) {
- target.put(JsonConstants.UIDL_KEY_INVOKER_ELEMENT, true);
- }
if (invocation.isSubscribed()) {
StateNode owner = invocation.getOwner();
diff --git a/flow-server/src/main/java/com/vaadin/flow/shared/JsonConstants.java b/flow-server/src/main/java/com/vaadin/flow/shared/JsonConstants.java
index e1e2e2cbcdb..99c0d489fb3 100644
--- a/flow-server/src/main/java/com/vaadin/flow/shared/JsonConstants.java
+++ b/flow-server/src/main/java/com/vaadin/flow/shared/JsonConstants.java
@@ -190,13 +190,6 @@ public class JsonConstants implements Serializable {
*/
public static final String UIDL_KEY_INVOKER_RETURNS = "returns";
- /**
- * Key that marks a JS invoker invocation whose parameter after the
- * arguments is the element to apply the function to. An invocation without
- * it runs the function with no this.
- */
- public static final String UIDL_KEY_INVOKER_ELEMENT = "element";
-
/**
* Key used to hold the feature id when synchronizing node values.
*/
diff --git a/flow-server/src/test/java/com/vaadin/flow/component/page/PageTest.java b/flow-server/src/test/java/com/vaadin/flow/component/page/PageTest.java
index e3f1c44a266..3328d24e6ea 100644
--- a/flow-server/src/test/java/com/vaadin/flow/component/page/PageTest.java
+++ b/flow-server/src/test/java/com/vaadin/flow/component/page/PageTest.java
@@ -15,7 +15,6 @@
*/
package com.vaadin.flow.component.page;
-import java.io.Serializable;
import java.net.URL;
import java.util.ArrayList;
import java.util.Arrays;
@@ -33,13 +32,8 @@
import tools.jackson.databind.JsonNode;
import com.vaadin.flow.component.UI;
-import com.vaadin.flow.component.internal.PendingJavaScriptInvocation;
-import com.vaadin.flow.component.internal.UIInternals.JavaScriptInvocation;
import com.vaadin.flow.function.SerializableConsumer;
import com.vaadin.flow.internal.JacksonUtils;
-import com.vaadin.flow.js.JsExpression;
-import com.vaadin.flow.js.JsInvoker;
-import com.vaadin.flow.js.JsInvokerCall;
import com.vaadin.flow.server.InitParameters;
import com.vaadin.flow.server.VaadinService;
import com.vaadin.flow.server.VaadinSession;
@@ -50,7 +44,6 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
-import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
@@ -93,55 +86,6 @@ public PendingJavaScriptResult executeJs(String expression,
private BrowserWindowResizeListener listener = event -> {
};
- @JsInvoker
- interface PageJs extends Serializable {
- @JsExpression("window.alert($0)")
- void showGreeting(String greeting);
-
- @JsExpression("return navigator.clipboard.readText()")
- PendingJavaScriptResult readText();
- }
-
- @Test
- void getJsInvoker_methodReturningAResult_answersWithTheExecution() {
- MockUI mockUI = new MockUI();
-
- PendingJavaScriptResult result = mockUI.getPage()
- .getJsInvoker(PageJs.class).readText();
- List values = new ArrayList<>();
- result.then(String.class, values::add);
-
- List invocations = mockUI.getInternals()
- .dumpPendingJavaScriptInvocations();
- assertEquals(1, invocations.size());
- assertSame(result, invocations.get(0),
- "the pending execution is what the method answers with");
- assertTrue(invocations.get(0).isSubscribed(),
- "the return value should be asked for from the client");
-
- invocations.get(0).complete(JacksonUtils.createNode("text"));
- assertEquals(List.of("text"), values);
- }
-
- @Test
- void getJsInvoker_schedulesTheCallWithoutAnElement() {
- MockUI mockUI = new MockUI();
-
- mockUI.getPage().getJsInvoker(PageJs.class).showGreeting("Hello");
-
- List invocations = mockUI.getInternals()
- .dumpPendingJavaScriptInvocations();
- assertEquals(1, invocations.size());
- JavaScriptInvocation invocation = invocations.get(0).getInvocation();
-
- assertEquals(new JsInvokerCall(PageJs.class, "showGreeting",
- List.of("Hello")), invocation.getInvokerCall());
- assertEquals(List.of("Hello"), invocation.getParameters(),
- "a page invoker has no element to apply the function to");
- assertEquals("window.alert($0)", invocation.getExpression(),
- "the declared expression should not be wrapped");
- }
-
@Test
void addNullAsAListener_trows() {
diff --git a/flow-server/src/test/java/com/vaadin/flow/server/communication/UidlWriterTest.java b/flow-server/src/test/java/com/vaadin/flow/server/communication/UidlWriterTest.java
index bf3eebdaa20..d31e5b719ce 100644
--- a/flow-server/src/test/java/com/vaadin/flow/server/communication/UidlWriterTest.java
+++ b/flow-server/src/test/java/com/vaadin/flow/server/communication/UidlWriterTest.java
@@ -222,7 +222,6 @@ void encodeExecuteJavaScript_invokerCall_sendsTheTargetInsteadOfTheScript() {
target.put("invoker", TestJs.class.getName());
target.put("method", "method/1");
target.put("arguments", 1);
- target.put("element", true);
ArrayNode expectedJson = JacksonUtils.createArray(
JacksonUtils.createArray(JacksonUtils.createNode("foo"),
// Null since element is not attached
@@ -259,55 +258,6 @@ void encodeExecuteJavaScript_subscribedInvokerCall_addsTheReturnChannels() {
assertEquals(1, target.get("arguments").asInt());
}
- @Test
- void encodeExecuteJavaScript_invokerCallWithoutAnElement_targetSaysSo() {
- Element element = ElementFactory.createDiv();
-
- // What a page invoker schedules: the arguments and nothing else, since
- // there is no element to apply the function to
- JsInvokerCall call = new JsInvokerCall(TestJs.class, "method",
- List.of("foo"));
- JavaScriptInvocation invocation = new JavaScriptInvocation(call,
- call.getExpression(), "foo");
-
- ArrayNode json = UidlWriter.encodeExecuteJavaScriptList(
- List.of(new PendingJavaScriptInvocation(element.getNode(),
- invocation)));
-
- ArrayNode encoded = (ArrayNode) json.get(0);
- assertEquals(2, encoded.size(),
- "the argument should be followed by the target alone: "
- + encoded);
- assertFalse(((ObjectNode) encoded.get(1)).has("element"),
- "without an element the function runs with no this");
- }
-
- @Test
- void encodeExecuteJavaScript_subscribedInvokerCallWithoutAnElement_channelsFollowTheArguments() {
- Element element = ElementFactory.createDiv();
-
- JsInvokerCall call = new JsInvokerCall(TestJs.class, "method",
- List.of("foo"));
- JavaScriptInvocation invocation = new JavaScriptInvocation(call,
- call.getExpression(), "foo");
- PendingJavaScriptInvocation pending = new PendingJavaScriptInvocation(
- element.getNode(), invocation);
- pending.then(value -> {
- });
-
- ArrayNode json = UidlWriter
- .encodeExecuteJavaScriptList(List.of(pending));
-
- ArrayNode encoded = (ArrayNode) json.get(0);
- assertEquals(4, encoded.size(),
- "the argument should be followed by the two channels and the target: "
- + encoded);
- ObjectNode target = (ObjectNode) encoded.get(3);
- assertTrue(target.get("returns").asBoolean());
- assertFalse(target.has("element"),
- "the channels follow the arguments when there is no element");
- }
-
@JsInvoker
interface TestJs extends Serializable {
@JsExpression("this.method($0)")
From 92dbe82c4d55a81a4e2429b25a4a4f8e8ef51f71 Mon Sep 17 00:00:00 2001
From: "totally-not-ai[bot]"
<290682512+totally-not-ai[bot]@users.noreply.github.com>
Date: Fri, 18 Sep 2026 12:06:59 +0000
Subject: [PATCH 17/57] fix: rebuild a bundle that carries no invoker
JavaScript
Keeping such a bundle meant running an application whose invoker calls
find no function at all, which is the opposite of what the check is for.
It was kept because the stats did not record the generated file, so every
bundle looked like it was missing it and every application rebuilt; now
that the file is hashed into the stats, a bundle without that hash really
is one built before the declarations existed, and rebuilding is the
answer.
---
.../server/frontend/BundleValidationUtil.java | 15 ++--
.../server/frontend/BundleValidationTest.java | 6 +-
flow-client/package-lock.json | 69 -------------------
3 files changed, 9 insertions(+), 81 deletions(-)
diff --git a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/BundleValidationUtil.java b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/BundleValidationUtil.java
index 2a616c6a035..745a64682c7 100644
--- a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/BundleValidationUtil.java
+++ b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/BundleValidationUtil.java
@@ -1020,16 +1020,13 @@ private static boolean jsInvokersChanged(Options options,
String content = new TaskGenerateJsInvokers(options).getFileContent();
if (!frontendHashes.has(jsInvokersPath)) {
- // A bundle built before invoker interfaces existed carries none of
- // their JavaScript. It is not rebuilt for that: an application
- // that runs on a precompiled bundle has deliberately no frontend
- // build, and one that does build its frontend generates the file
- // as part of the build. What it means is that a call made through
- // an invoker finds nothing to run until the bundle is built again,
- // which the client reports per call, so say it once here as well.
+ // Every build that knows about invoker interfaces records what it
+ // generated for them, so a bundle without that is one built before
+ // they existed: it carries none of their JavaScript, and a call
+ // made through an invoker would find nothing to run.
getLogger().info(
- "The bundle in use was built without the JavaScript declared by @JsInvoker interfaces. Calls made through an invoker will not run until the frontend is built again.");
- return false;
+ "Detected a bundle that was built without the JavaScript declared by the invoker interfaces");
+ return true;
}
List faultyContent = new ArrayList<>();
diff --git a/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/BundleValidationTest.java b/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/BundleValidationTest.java
index c31ab1b1e7d..19cba05eb39 100644
--- a/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/BundleValidationTest.java
+++ b/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/BundleValidationTest.java
@@ -1074,7 +1074,7 @@ void frontendFileHashMatches_noBundleRebuild(Mode mode) throws IOException {
@ParameterizedTest
@MethodSource("modes")
- void bundleWithoutJsInvokerJavaScript_noBundleRebuild(Mode mode) {
+ void bundleWithoutJsInvokerJavaScript_bundleRebuild(Mode mode) {
setupMode(mode);
ObjectNode stats = getBasicStats();
@@ -1085,8 +1085,8 @@ void bundleWithoutJsInvokerJavaScript_noBundleRebuild(Mode mode) {
boolean needsBuild = BundleValidationUtil.needsBuild(options,
depScanner, mode);
- assertFalse(needsBuild,
- "a bundle that predates invoker interfaces should keep being used, since an application running on a precompiled bundle has no frontend build to replace it with");
+ assertTrue(needsBuild,
+ "a bundle that carries none of the JavaScript the invoker interfaces declare would run an application that cannot make those calls");
}
@ParameterizedTest
diff --git a/flow-client/package-lock.json b/flow-client/package-lock.json
index 8d4a5d408f7..509bc1be634 100644
--- a/flow-client/package-lock.json
+++ b/flow-client/package-lock.json
@@ -1512,9 +1512,6 @@
"arm"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1529,9 +1526,6 @@
"arm"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1546,9 +1540,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1563,9 +1554,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1580,9 +1568,6 @@
"loong64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1597,9 +1582,6 @@
"loong64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1614,9 +1596,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1631,9 +1610,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1648,9 +1624,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1665,9 +1638,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1682,9 +1652,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1699,9 +1666,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1716,9 +1680,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3227,9 +3188,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3244,9 +3202,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3261,9 +3216,6 @@
"loong64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3278,9 +3230,6 @@
"loong64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3295,9 +3244,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3312,9 +3258,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3329,9 +3272,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3346,9 +3286,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3363,9 +3300,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3380,9 +3314,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
From bc0ed5062b6137b27b18a5f48b57b644354dd49e Mon Sep 17 00:00:00 2001
From: "totally-not-ai[bot]"
<290682512+totally-not-ai[bot]@users.noreply.github.com>
Date: Fri, 18 Sep 2026 12:07:13 +0000
Subject: [PATCH 18/57] chore: restore the client lockfile
An install on this machine dropped the libc fields of a few optional
platform packages from it; nothing in this branch changes what the client
depends on.
---
flow-client/package-lock.json | 69 +++++++++++++++++++++++++++++++++++
1 file changed, 69 insertions(+)
diff --git a/flow-client/package-lock.json b/flow-client/package-lock.json
index 509bc1be634..8d4a5d408f7 100644
--- a/flow-client/package-lock.json
+++ b/flow-client/package-lock.json
@@ -1512,6 +1512,9 @@
"arm"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -1526,6 +1529,9 @@
"arm"
],
"dev": true,
+ "libc": [
+ "musl"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -1540,6 +1546,9 @@
"arm64"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -1554,6 +1563,9 @@
"arm64"
],
"dev": true,
+ "libc": [
+ "musl"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -1568,6 +1580,9 @@
"loong64"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -1582,6 +1597,9 @@
"loong64"
],
"dev": true,
+ "libc": [
+ "musl"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -1596,6 +1614,9 @@
"ppc64"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -1610,6 +1631,9 @@
"ppc64"
],
"dev": true,
+ "libc": [
+ "musl"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -1624,6 +1648,9 @@
"riscv64"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -1638,6 +1665,9 @@
"riscv64"
],
"dev": true,
+ "libc": [
+ "musl"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -1652,6 +1682,9 @@
"s390x"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -1666,6 +1699,9 @@
"x64"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -1680,6 +1716,9 @@
"x64"
],
"dev": true,
+ "libc": [
+ "musl"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -3188,6 +3227,9 @@
"arm64"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -3202,6 +3244,9 @@
"arm64"
],
"dev": true,
+ "libc": [
+ "musl"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -3216,6 +3261,9 @@
"loong64"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -3230,6 +3278,9 @@
"loong64"
],
"dev": true,
+ "libc": [
+ "musl"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -3244,6 +3295,9 @@
"ppc64"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -3258,6 +3312,9 @@
"riscv64"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -3272,6 +3329,9 @@
"riscv64"
],
"dev": true,
+ "libc": [
+ "musl"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -3286,6 +3346,9 @@
"s390x"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -3300,6 +3363,9 @@
"x64"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -3314,6 +3380,9 @@
"x64"
],
"dev": true,
+ "libc": [
+ "musl"
+ ],
"license": "MIT",
"optional": true,
"os": [
From 6370ab205c4feeee375f0ca8ca9ce02010f97043 Mon Sep 17 00:00:00 2001
From: "totally-not-ai[bot]"
<290682512+totally-not-ai[bot]@users.noreply.github.com>
Date: Fri, 18 Sep 2026 12:21:50 +0000
Subject: [PATCH 19/57] refactor: name the generator methods for what they do
renderFileContent, renderInvokerLines and readInvokerNames, so each one
says whether it writes the format or reads it.
Reading the generated file at runtime also goes the way the other
generated files are read: FrontendUtils serves index.html and
web-component.html through one helper that takes them from the dev server
while it runs, from the project otherwise and from the class path in
production, and the invoker file now has its own entry point there
instead of the hotswapper reading the path itself. The test resolves the
folder the way the production code does, so it exercises that path rather
than a folder of its own.
---
.../frontend/TaskGenerateJsInvokers.java | 17 +++----
.../vaadin/flow/internal/FrontendUtils.java | 21 +++++++++
.../frontend/generated/vaadin-js-invokers.js | 18 +++++++
.../hotswap/impl/JsInvokerHotswapper.java | 47 ++++++++-----------
.../hotswap/impl/JsInvokerHotswapperTest.java | 15 +++---
5 files changed, 77 insertions(+), 41 deletions(-)
create mode 100644 vaadin-dev-server/src/main/frontend/generated/vaadin-js-invokers.js
diff --git a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokers.java b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokers.java
index e3b3b554be3..a850b482005 100644
--- a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokers.java
+++ b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokers.java
@@ -59,7 +59,7 @@ public class TaskGenerateJsInvokers extends AbstractTaskClientGenerator {
@Override
protected String getFileContent() {
- return fileContent(
+ return renderFileContent(
options.getClassFinder().getAnnotatedClasses(JsInvoker.class));
}
@@ -75,7 +75,7 @@ protected String getFileContent() {
* the invoker interfaces to render, not null
* @return the content of the generated file
*/
- public static String fileContent(Collection> invokers) {
+ public static String renderFileContent(Collection> invokers) {
List lines = new ArrayList<>();
lines.add("// @ts-nocheck");
lines.add("window.Vaadin = window.Vaadin || {};");
@@ -84,7 +84,7 @@ public static String fileContent(Collection> invokers) {
"window.Vaadin.Flow.jsInvokers = window.Vaadin.Flow.jsInvokers || {};");
invokers.stream().sorted(Comparator.comparing(Class::getName))
- .forEach(invoker -> lines.addAll(invokerLines(invoker)));
+ .forEach(invoker -> lines.addAll(renderInvokerLines(invoker)));
// Writing this file again while the application runs replaces it in the
// browser that has it: everything above only writes into the registry,
@@ -106,16 +106,17 @@ public static String fileContent(Collection> invokers) {
* Reads back the names of the invoker interfaces a generated file
* registers, which is what a browser that has the file can run.
*
- * Exposed together with {@link #invokerLines(Class)} so that the format
- * this class writes is also read here, and a caller which has to render the
- * file again - the hotswap path - can keep the interfaces that are in it.
+ * Exposed together with {@link #renderInvokerLines(Class)} so that the
+ * format this class writes is also read here, and a caller which has to
+ * render the file again - the hotswap path - can keep the interfaces that
+ * are in it.
*
* @param fileContent
* the content of a generated file, or null
* @return the interface names the file registers, in the order it registers
* them
*/
- public static List invokerNames(String fileContent) {
+ public static List readInvokerNames(String fileContent) {
List names = new ArrayList<>();
if (fileContent == null) {
return names;
@@ -144,7 +145,7 @@ public static List invokerNames(String fileContent) {
* @return the lines this invoker contributes, empty if it declares no
* JavaScript
*/
- public static List invokerLines(Class> invoker) {
+ public static List renderInvokerLines(Class> invoker) {
List lines = new ArrayList<>();
List methods = new ArrayList<>();
for (Method method : invoker.getMethods()) {
diff --git a/flow-server/src/main/java/com/vaadin/flow/internal/FrontendUtils.java b/flow-server/src/main/java/com/vaadin/flow/internal/FrontendUtils.java
index c4f131a1944..63c33fb300a 100644
--- a/flow-server/src/main/java/com/vaadin/flow/internal/FrontendUtils.java
+++ b/flow-server/src/main/java/com/vaadin/flow/internal/FrontendUtils.java
@@ -575,6 +575,27 @@ public static String getWebComponentHtmlContent(VaadinService service)
return getFileContent(service, WEB_COMPONENT_HTML);
}
+ /**
+ * Gets the content of the generated
+ * frontend/generated/{@value #JS_INVOKERS_FILE_NAME} file,
+ * which registers the JavaScript that the {@code @JsInvoker} interfaces of
+ * the application declare, and is therefore what a browser can run of it.
+ *
+ * Read the same way as the other generated files: from the dev server while
+ * it is running, from the project otherwise, and from the class path in
+ * production.
+ *
+ * @param service
+ * the Vaadin service
+ * @return the content of the file, or null if there is none
+ * @throws IOException
+ * on error when reading the file
+ */
+ public static String getJsInvokersContent(VaadinService service)
+ throws IOException {
+ return getFileContent(service, GENERATED + JS_INVOKERS_FILE_NAME);
+ }
+
private static String getFileContent(VaadinService service, String path)
throws IOException {
DeploymentConfiguration config = service.getDeploymentConfiguration();
diff --git a/vaadin-dev-server/src/main/frontend/generated/vaadin-js-invokers.js b/vaadin-dev-server/src/main/frontend/generated/vaadin-js-invokers.js
new file mode 100644
index 00000000000..46d34391920
--- /dev/null
+++ b/vaadin-dev-server/src/main/frontend/generated/vaadin-js-invokers.js
@@ -0,0 +1,18 @@
+// @ts-nocheck
+window.Vaadin = window.Vaadin || {};
+window.Vaadin.Flow = window.Vaadin.Flow || {};
+window.Vaadin.Flow.jsInvokers = window.Vaadin.Flow.jsInvokers || {};
+window.Vaadin.Flow.jsInvokers['com.vaadin.base.devserver.hotswap.impl.JsInvokerHotswapperTest$GreeterJs'] =
+ Object.assign(
+ window.Vaadin.Flow.jsInvokers['com.vaadin.base.devserver.hotswap.impl.JsInvokerHotswapperTest$GreeterJs'] || {},
+ {
+ 'showGreeting/1': async function ($0) {
+ window.alert($0);
+ this.focus();
+ }
+ }
+ );
+if (import.meta.hot) {
+ import.meta.hot.accept();
+}
+export {};
diff --git a/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapper.java b/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapper.java
index 6efbcc66b35..380b385b470 100644
--- a/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapper.java
+++ b/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapper.java
@@ -33,6 +33,7 @@
import com.vaadin.flow.js.JsExpression;
import com.vaadin.flow.js.JsInvoker;
import com.vaadin.flow.server.Mode;
+import com.vaadin.flow.server.VaadinService;
import com.vaadin.flow.server.frontend.TaskGenerateJsInvokers;
import com.vaadin.flow.server.startup.ApplicationConfiguration;
@@ -72,10 +73,9 @@ public void onClassesChange(HotswapClassEvent event) {
return;
}
- ApplicationConfiguration configuration = ApplicationConfiguration
- .get(event.getVaadinService().getContext());
- File generatedFile = generatedInvokersFile(configuration);
- String generated = readGeneratedInvokers(generatedFile);
+ VaadinService service = event.getVaadinService();
+ File generatedFile = generatedInvokersFile(service);
+ String generated = readGeneratedInvokers(service);
List> stale = invokers.stream()
.filter(invoker -> !isInBundle(invoker, generated)).toList();
@@ -83,8 +83,7 @@ public void onClassesChange(HotswapClassEvent event) {
return;
}
- String applied = hotApply(configuration, generatedFile, generated,
- invokers);
+ String applied = hotApply(service, generatedFile, generated, invokers);
// What the file holds now is what a browser can run, so anything the
// rendering did not cover is still a change nobody can apply
List unresolved = stale.stream()
@@ -111,16 +110,17 @@ public void onClassesChange(HotswapClassEvent event) {
* @return the content the file holds afterwards, which is the content it
* held already when nothing could be written
*/
- private static String hotApply(ApplicationConfiguration configuration,
- File generatedFile, String generated,
- List> changedInvokers) {
+ private static String hotApply(VaadinService service, File generatedFile,
+ String generated, List> changedInvokers) {
+ ApplicationConfiguration configuration = ApplicationConfiguration
+ .get(service.getContext());
if (generatedFile == null || configuration == null || configuration
.getMode() != Mode.DEVELOPMENT_FRONTEND_LIVERELOAD) {
return generated;
}
try {
- String content = TaskGenerateJsInvokers
- .fileContent(invokersToRender(generated, changedInvokers));
+ String content = TaskGenerateJsInvokers.renderFileContent(
+ invokersToRender(generated, changedInvokers));
if (content.equals(generated)) {
return generated;
}
@@ -150,7 +150,7 @@ private static Collection> invokersToRender(String generated,
changedInvokers
.forEach(invoker -> byName.put(invoker.getName(), invoker));
ClassLoader classLoader = changedInvokers.get(0).getClassLoader();
- for (String name : TaskGenerateJsInvokers.invokerNames(generated)) {
+ for (String name : TaskGenerateJsInvokers.readInvokerNames(generated)) {
if (byName.containsKey(name)) {
continue;
}
@@ -188,7 +188,8 @@ private static boolean isInBundle(Class> invoker, String generated) {
// Nothing carries the declarations, so nothing matches them
return false;
}
- List declared = TaskGenerateJsInvokers.invokerLines(invoker);
+ List declared = TaskGenerateJsInvokers
+ .renderInvokerLines(invoker);
if (declared.isEmpty()) {
// Declares no JavaScript, so there is nothing to carry
return true;
@@ -197,13 +198,9 @@ private static boolean isInBundle(Class> invoker, String generated) {
.contains(String.join(System.lineSeparator(), declared));
}
- private static File generatedInvokersFile(
- ApplicationConfiguration configuration) {
- if (configuration == null) {
- return null;
- }
+ private static File generatedInvokersFile(VaadinService service) {
File frontendFolder = FrontendUtils
- .getProjectFrontendDir(configuration);
+ .getProjectFrontendDir(service.getDeploymentConfiguration());
if (frontendFolder == null) {
return null;
}
@@ -212,15 +209,11 @@ private static File generatedInvokersFile(
FrontendUtils.JS_INVOKERS_FILE_NAME);
}
- private static String readGeneratedInvokers(File generatedFile) {
- if (generatedFile == null || !generatedFile.exists()) {
- return null;
- }
+ private static String readGeneratedInvokers(VaadinService service) {
try {
- return Files.readString(generatedFile.toPath(),
- StandardCharsets.UTF_8);
- } catch (IOException e) {
- getLogger().debug("Could not read {}", generatedFile, e);
+ return FrontendUtils.getJsInvokersContent(service);
+ } catch (IOException | RuntimeException e) {
+ getLogger().debug("Could not read the generated invokers", e);
return null;
}
}
diff --git a/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapperTest.java b/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapperTest.java
index a5049ee611c..2ee6cc79e71 100644
--- a/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapperTest.java
+++ b/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapperTest.java
@@ -81,13 +81,16 @@ void report(List invokerNames) {
@BeforeEach
void setUp() {
hotswapper = new TestHotswapper();
- frontendFolder = new File(projectFolder, FrontendUtils.FRONTEND);
- service = new MockVaadinServletService(
- new MockDeploymentConfiguration());
+ MockDeploymentConfiguration deploymentConfiguration = new MockDeploymentConfiguration();
+ deploymentConfiguration.setProjectFolder(projectFolder);
+ service = new MockVaadinServletService(deploymentConfiguration);
+ // Resolved the way the production code resolves it, so a case writes
+ // the file where the hotswapper looks for it
+ frontendFolder = FrontendUtils
+ .getProjectFrontendDir(deploymentConfiguration);
+
configuration = Mockito.mock(ApplicationConfiguration.class);
- Mockito.when(configuration.getFrontendFolder())
- .thenReturn(frontendFolder);
// What a browser runs without the frontend dev server is a bundle,
// which a case has to opt out of to get the file written again.
Mockito.when(configuration.getMode())
@@ -128,7 +131,7 @@ private void writeGeneratedInvokers(String content) throws IOException {
private String generatedFor(Class> invoker) {
return String.join(System.lineSeparator(),
- TaskGenerateJsInvokers.invokerLines(invoker));
+ TaskGenerateJsInvokers.renderInvokerLines(invoker));
}
private void classesChanged(Class>... classes) {
From 6d014994be24bdef91d9eb6c0f5031ed64e7c2be Mon Sep 17 00:00:00 2001
From: "totally-not-ai[bot]"
<290682512+totally-not-ai[bot]@users.noreply.github.com>
Date: Fri, 18 Sep 2026 12:22:05 +0000
Subject: [PATCH 20/57] chore: drop a generated file that a test run left
behind
It is written by the build into the frontend folder of an application,
not something this repository carries.
---
.../frontend/generated/vaadin-js-invokers.js | 18 ------------------
1 file changed, 18 deletions(-)
delete mode 100644 vaadin-dev-server/src/main/frontend/generated/vaadin-js-invokers.js
diff --git a/vaadin-dev-server/src/main/frontend/generated/vaadin-js-invokers.js b/vaadin-dev-server/src/main/frontend/generated/vaadin-js-invokers.js
deleted file mode 100644
index 46d34391920..00000000000
--- a/vaadin-dev-server/src/main/frontend/generated/vaadin-js-invokers.js
+++ /dev/null
@@ -1,18 +0,0 @@
-// @ts-nocheck
-window.Vaadin = window.Vaadin || {};
-window.Vaadin.Flow = window.Vaadin.Flow || {};
-window.Vaadin.Flow.jsInvokers = window.Vaadin.Flow.jsInvokers || {};
-window.Vaadin.Flow.jsInvokers['com.vaadin.base.devserver.hotswap.impl.JsInvokerHotswapperTest$GreeterJs'] =
- Object.assign(
- window.Vaadin.Flow.jsInvokers['com.vaadin.base.devserver.hotswap.impl.JsInvokerHotswapperTest$GreeterJs'] || {},
- {
- 'showGreeting/1': async function ($0) {
- window.alert($0);
- this.focus();
- }
- }
- );
-if (import.meta.hot) {
- import.meta.hot.accept();
-}
-export {};
From fed49877310f3f1f02b7e000c3aa94725b46b1b4 Mon Sep 17 00:00:00 2001
From: "totally-not-ai[bot]"
<290682512+totally-not-ai[bot]@users.noreply.github.com>
Date: Fri, 18 Sep 2026 12:33:28 +0000
Subject: [PATCH 21/57] fix: read the invoker file from the frontend folder,
not the dev server
Reading it through the helper that serves index.html took it from the dev
server while that is running, which is exactly the mode where the file is
written again: the answer would be the module as the dev server
transforms it, so comparing it with what a declaration renders to could
never match, and the request is not one of the paths that handler serves
anyway.
The file in the frontend folder is what the dev server reads and what
this writes, so reading it there keeps the comparison and the write on
the same bytes. The entry point added for the other way is gone with it.
---
.../vaadin/flow/internal/FrontendUtils.java | 21 -------------------
.../hotswap/impl/JsInvokerHotswapper.java | 21 ++++++++++++++-----
2 files changed, 16 insertions(+), 26 deletions(-)
diff --git a/flow-server/src/main/java/com/vaadin/flow/internal/FrontendUtils.java b/flow-server/src/main/java/com/vaadin/flow/internal/FrontendUtils.java
index 63c33fb300a..c4f131a1944 100644
--- a/flow-server/src/main/java/com/vaadin/flow/internal/FrontendUtils.java
+++ b/flow-server/src/main/java/com/vaadin/flow/internal/FrontendUtils.java
@@ -575,27 +575,6 @@ public static String getWebComponentHtmlContent(VaadinService service)
return getFileContent(service, WEB_COMPONENT_HTML);
}
- /**
- * Gets the content of the generated
- * frontend/generated/{@value #JS_INVOKERS_FILE_NAME} file,
- * which registers the JavaScript that the {@code @JsInvoker} interfaces of
- * the application declare, and is therefore what a browser can run of it.
- *
- * Read the same way as the other generated files: from the dev server while
- * it is running, from the project otherwise, and from the class path in
- * production.
- *
- * @param service
- * the Vaadin service
- * @return the content of the file, or null if there is none
- * @throws IOException
- * on error when reading the file
- */
- public static String getJsInvokersContent(VaadinService service)
- throws IOException {
- return getFileContent(service, GENERATED + JS_INVOKERS_FILE_NAME);
- }
-
private static String getFileContent(VaadinService service, String path)
throws IOException {
DeploymentConfiguration config = service.getDeploymentConfiguration();
diff --git a/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapper.java b/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapper.java
index 380b385b470..28181c9d6ea 100644
--- a/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapper.java
+++ b/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapper.java
@@ -75,7 +75,7 @@ public void onClassesChange(HotswapClassEvent event) {
VaadinService service = event.getVaadinService();
File generatedFile = generatedInvokersFile(service);
- String generated = readGeneratedInvokers(service);
+ String generated = readGeneratedInvokers(generatedFile);
List> stale = invokers.stream()
.filter(invoker -> !isInBundle(invoker, generated)).toList();
@@ -209,11 +209,22 @@ private static File generatedInvokersFile(VaadinService service) {
FrontendUtils.JS_INVOKERS_FILE_NAME);
}
- private static String readGeneratedInvokers(VaadinService service) {
+ /**
+ * Reads the generated file from the frontend folder, which is the file the
+ * dev server reads and this class writes, so what is compared and what is
+ * written are the same bytes. Fetching it from the dev server instead would
+ * answer with the module as it transforms it, which is not what a
+ * declaration renders to.
+ */
+ private static String readGeneratedInvokers(File generatedFile) {
+ if (generatedFile == null || !generatedFile.exists()) {
+ return null;
+ }
try {
- return FrontendUtils.getJsInvokersContent(service);
- } catch (IOException | RuntimeException e) {
- getLogger().debug("Could not read the generated invokers", e);
+ return Files.readString(generatedFile.toPath(),
+ StandardCharsets.UTF_8);
+ } catch (IOException e) {
+ getLogger().debug("Could not read {}", generatedFile, e);
return null;
}
}
From 1a2832a3e23e396474c877fb5e9e27dd1f70d063 Mon Sep 17 00:00:00 2001
From: "totally-not-ai[bot]"
<290682512+totally-not-ai[bot]@users.noreply.github.com>
Date: Fri, 18 Sep 2026 14:39:07 +0000
Subject: [PATCH 22/57] feat: check an invoker interface when it is handed out
Every method the invoker has to answer has to declare the JavaScript it
runs and return either nothing or the pending result of running it. Both
were found at the first call before, or in the browser, where an
interface that can not work is better refused as soon as it is asked for.
A default method is exempt and now runs in Java, so an interface can
compose calls of its own methods rather than having to declare every one
of them.
---
.../java/com/vaadin/flow/dom/Element.java | 60 ++++++++++++++++---
.../java/com/vaadin/flow/dom/ElementTest.java | 53 ++++++++++++++--
2 files changed, 98 insertions(+), 15 deletions(-)
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 9d89020b1d0..fc945c73b27 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
@@ -18,7 +18,9 @@
import java.io.Serializable;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
import java.lang.reflect.Proxy;
+import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
@@ -1989,11 +1991,54 @@ public T getJsInvoker(Class invokerType) {
+ " is not annotated with @JsInvoker, so the build does not"
+ " collect its JavaScript into the bundle");
}
+ checkInvokerMethods(invokerType);
return (T) Proxy.newProxyInstance(invokerType.getClassLoader(),
new Class>[] { invokerType },
new JsInvokerHandler(this, invokerType));
}
+ /**
+ * Checks the methods of an invoker interface: each one that the invoker has
+ * to answer declares the JavaScript it runs, and returns either nothing or
+ * the pending result of running it. Checked here rather than when a method
+ * is called, so an interface that can not work says so when it is handed
+ * out.
+ *
+ * A default method is not checked: it runs in Java, and composing calls of
+ * the interface is what it is for. Neither is a static one.
+ */
+ private static void checkInvokerMethods(Class> invokerType) {
+ List undeclared = new ArrayList<>();
+ List unanswerable = new ArrayList<>();
+ for (Method method : invokerType.getMethods()) {
+ if (method.isDefault()
+ || Modifier.isStatic(method.getModifiers())) {
+ continue;
+ }
+ if (!method.isAnnotationPresent(JsExpression.class)) {
+ undeclared.add(method.getName());
+ }
+ Class> returnType = method.getReturnType();
+ if (returnType != void.class && !returnType
+ .isAssignableFrom(PendingJavaScriptResult.class)) {
+ unanswerable.add(method.getName());
+ }
+ }
+ if (!undeclared.isEmpty()) {
+ throw new IllegalArgumentException(invokerType.getName()
+ + " declares no JavaScript for "
+ + String.join(", ", undeclared)
+ + ". Annotate the methods with @JsExpression, or make them"
+ + " default methods if they are meant to run in Java");
+ }
+ if (!unanswerable.isEmpty()) {
+ throw new IllegalArgumentException(invokerType.getName() + " has "
+ + String.join(", ", unanswerable)
+ + " returning something the invoker can not answer with."
+ + " A method returns void or PendingJavaScriptResult");
+ }
+ }
+
/**
* Turns a call on a JS invoker interface into a scheduled invocation that
* carries the call.
@@ -2007,16 +2052,13 @@ public Object invoke(Object proxy, Method method, Object[] args)
if (method.getDeclaringClass() == Object.class) {
return method.invoke(this, args);
}
- Class> returnType = method.getReturnType();
- boolean returnsResult = returnType
- .isAssignableFrom(PendingJavaScriptResult.class);
- // Checked before scheduling, so that a method the invoker can not
- // answer does not run in the browser either
- if (returnType != void.class && !returnsResult) {
- throw new IllegalStateException("Method " + method.getName()
- + " of " + invokerType.getName()
- + " must return void or PendingJavaScriptResult");
+ if (method.isDefault()) {
+ // Runs in Java, and what it calls of the interface comes back
+ // here
+ return InvocationHandler.invokeDefault(proxy, method, args);
}
+ boolean returnsResult = method.getReturnType()
+ .isAssignableFrom(PendingJavaScriptResult.class);
List arguments = args == null ? List.of()
: Arrays.asList(args);
PendingJavaScriptResult result = element
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 cd7cb84a522..0c60a5f1943 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
@@ -2725,18 +2725,43 @@ void getJsInvoker_methodReturningAResult_schedulesAndReturnsIt() {
}
@Test
- void getJsInvoker_methodWithAnotherReturnType_throwsAndSchedulesNothing() {
+ void getJsInvoker_methodWithAnotherReturnType_throws() {
+ Element element = ElementFactory.createDiv();
+
+ assertThrows(IllegalArgumentException.class,
+ () -> element.getJsInvoker(UnsupportedJs.class),
+ "a method the invoker can not answer should be refused when the invoker is handed out");
+ }
+
+ @Test
+ void getJsInvoker_methodWithoutDeclaredJavaScript_throws() {
+ Element element = ElementFactory.createDiv();
+
+ IllegalArgumentException exception = assertThrows(
+ IllegalArgumentException.class,
+ () -> element.getJsInvoker(UndeclaredJs.class));
+
+ assertTrue(exception.getMessage().contains("undeclared"),
+ "the message should name the method that declares nothing: "
+ + exception.getMessage());
+ }
+
+ @Test
+ void getJsInvoker_defaultMethod_runsInJavaAndSchedulesWhatItCalls() {
UI ui = new MockUI();
Element element = ElementFactory.createDiv();
ui.getElement().appendChild(element);
- assertThrows(IllegalStateException.class,
- () -> element.getJsInvoker(UnsupportedJs.class).readValue());
+ element.getJsInvoker(ComposingJs.class).twice("foo");
ui.getInternals().getStateTree().runExecutionsBeforeClientResponse();
- assertTrue(
- ui.getInternals().dumpPendingJavaScriptInvocations().isEmpty(),
- "a method the invoker can not answer should not run in the browser either");
+ List pendingJs = ui.getInternals()
+ .dumpPendingJavaScriptInvocations();
+ assertEquals(2, pendingJs.size(),
+ "a default method runs in Java, and what it calls of the interface is scheduled");
+ assertEquals(
+ new JsInvokerCall(ComposingJs.class, "method", List.of("foo")),
+ pendingJs.get(0).getInvocation().getInvokerCall());
}
@JsInvoker
@@ -2751,6 +2776,22 @@ interface UnsupportedJs extends Serializable {
String readValue();
}
+ @JsInvoker
+ interface UndeclaredJs extends Serializable {
+ void undeclared();
+ }
+
+ @JsInvoker
+ interface ComposingJs extends Serializable {
+ @JsExpression("this.method($0)")
+ void method(String value);
+
+ default void twice(String value) {
+ method(value);
+ method(value);
+ }
+ }
+
@JsInvoker
interface TestJs extends Serializable {
@JsExpression("this.method($0)")
From f7eb1f98a4713f280993c5e4ca7a40115531c168 Mon Sep 17 00:00:00 2001
From: "totally-not-ai[bot]"
<290682512+totally-not-ai[bot]@users.noreply.github.com>
Date: Fri, 18 Sep 2026 14:47:27 +0000
Subject: [PATCH 23/57] fix: say what a default method on an invoker interface
needs
Running one is an ordinary Java call made from here, which an interface
that is not public cannot answer - it failed at the call instead, with the
access error wrapped in an undeclared throwable. The interface is now
refused when it is handed out, and the call itself says the same thing if
it still cannot be made.
A default method that also declares JavaScript is refused too: only one
of the two can happen. The build no longer generates a function for such
a method either, so nothing in a bundle waits for a call that cannot
arrive.
---
.../frontend/TaskGenerateJsInvokers.java | 5 +-
.../java/com/vaadin/flow/dom/Element.java | 50 +++++++++++--
.../java/com/vaadin/flow/dom/ElementTest.java | 70 ++++++++++++++++++-
3 files changed, 116 insertions(+), 9 deletions(-)
diff --git a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokers.java b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokers.java
index a850b482005..a73156599f6 100644
--- a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokers.java
+++ b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokers.java
@@ -149,7 +149,10 @@ public static List renderInvokerLines(Class> invoker) {
List lines = new ArrayList<>();
List methods = new ArrayList<>();
for (Method method : invoker.getMethods()) {
- if (method.isAnnotationPresent(JsExpression.class)) {
+ // A default method runs in Java, so it has nothing in the bundle
+ // even if it carries the annotation
+ if (method.isAnnotationPresent(JsExpression.class)
+ && !method.isDefault()) {
methods.add(method);
}
}
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 fc945c73b27..5d5979666d5 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
@@ -1971,13 +1971,23 @@ public PendingJavaScriptResult executeJs(String expression,
* or run it on its own implementation of the same interface.
*
* A method returns either void or
- * {@link PendingJavaScriptResult}.
+ * {@link PendingJavaScriptResult}. A default method declares
+ * no JavaScript and runs in Java instead, so an interface can compose calls
+ * of its own methods; an interface that has one has to be public, since
+ * running it is an ordinary Java call. A static method is left
+ * alone for the same reason.
+ *
+ * The interface is checked when the invoker is handed out, so one that can
+ * not work says so here rather than at the first call.
*
* @param
* the invoker interface type
* @param invokerType
* the invoker interface, not null
* @return an invoker bound to this element, not null
+ * @throws IllegalArgumentException
+ * if the type is not an interface, is not annotated with
+ * {@link JsInvoker}, or has a method the invoker can not answer
*/
@SuppressWarnings("unchecked")
public T getJsInvoker(Class invokerType) {
@@ -2010,9 +2020,18 @@ public T getJsInvoker(Class invokerType) {
private static void checkInvokerMethods(Class> invokerType) {
List undeclared = new ArrayList<>();
List unanswerable = new ArrayList<>();
+ List inJava = new ArrayList<>();
for (Method method : invokerType.getMethods()) {
- if (method.isDefault()
- || Modifier.isStatic(method.getModifiers())) {
+ if (Modifier.isStatic(method.getModifiers())) {
+ continue;
+ }
+ if (method.isDefault()) {
+ if (method.isAnnotationPresent(JsExpression.class)) {
+ // Declaring JavaScript and running in Java at the same
+ // time: only one of them can happen, so neither is assumed
+ undeclared.add(method.getName());
+ }
+ inJava.add(method.getName());
continue;
}
if (!method.isAnnotationPresent(JsExpression.class)) {
@@ -2026,10 +2045,11 @@ private static void checkInvokerMethods(Class> invokerType) {
}
if (!undeclared.isEmpty()) {
throw new IllegalArgumentException(invokerType.getName()
- + " declares no JavaScript for "
+ + " declares no JavaScript to run for "
+ String.join(", ", undeclared)
+ ". Annotate the methods with @JsExpression, or make them"
- + " default methods if they are meant to run in Java");
+ + " default methods, without the annotation, if they are"
+ + " meant to run in Java");
}
if (!unanswerable.isEmpty()) {
throw new IllegalArgumentException(invokerType.getName() + " has "
@@ -2037,6 +2057,16 @@ private static void checkInvokerMethods(Class> invokerType) {
+ " returning something the invoker can not answer with."
+ " A method returns void or PendingJavaScriptResult");
}
+ if (!inJava.isEmpty()
+ && !Modifier.isPublic(invokerType.getModifiers())) {
+ // Running a default method is an ordinary Java call, made from
+ // here, so the interface has to be reachable from here
+ throw new IllegalArgumentException(invokerType.getName() + " has "
+ + String.join(", ", inJava)
+ + " running in Java, which an interface that is not public"
+ + " can not do. Make the interface public, or declare the"
+ + " JavaScript of those methods with @JsExpression");
+ }
}
/**
@@ -2055,7 +2085,15 @@ public Object invoke(Object proxy, Method method, Object[] args)
if (method.isDefault()) {
// Runs in Java, and what it calls of the interface comes back
// here
- return InvocationHandler.invokeDefault(proxy, method, args);
+ try {
+ return InvocationHandler.invokeDefault(proxy, method, args);
+ } catch (IllegalAccessException e) {
+ throw new IllegalStateException("Cannot run "
+ + method.getName() + " of " + invokerType.getName()
+ + " in Java. Make the interface public, or declare"
+ + " the JavaScript of the method with"
+ + " @JsExpression", e);
+ }
}
boolean returnsResult = method.getReturnType()
.isAssignableFrom(PendingJavaScriptResult.class);
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 0c60a5f1943..d73c10eb466 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
@@ -2728,9 +2728,14 @@ void getJsInvoker_methodReturningAResult_schedulesAndReturnsIt() {
void getJsInvoker_methodWithAnotherReturnType_throws() {
Element element = ElementFactory.createDiv();
- assertThrows(IllegalArgumentException.class,
+ IllegalArgumentException exception = assertThrows(
+ IllegalArgumentException.class,
() -> element.getJsInvoker(UnsupportedJs.class),
"a method the invoker can not answer should be refused when the invoker is handed out");
+
+ assertTrue(exception.getMessage().contains("readValue"),
+ "the message should name the method: "
+ + exception.getMessage());
}
@Test
@@ -2746,6 +2751,41 @@ void getJsInvoker_methodWithoutDeclaredJavaScript_throws() {
+ exception.getMessage());
}
+ @Test
+ void getJsInvoker_defaultAndStaticMethods_areNotDeclarations() {
+ Element element = ElementFactory.createDiv();
+
+ ComposingJs invoker = element.getJsInvoker(ComposingJs.class);
+
+ // A static method belongs to the interface, not to the invoker, and a
+ // default method answers with whatever Java answers with
+ assertEquals("ComposingJs", ComposingJs.name());
+ assertEquals("composing", invoker.describe(),
+ "a default method is not bound by what a declared one may return");
+ }
+
+ @Test
+ void getJsInvoker_defaultMethodOnANonPublicInterface_throws() {
+ Element element = ElementFactory.createDiv();
+
+ IllegalArgumentException exception = assertThrows(
+ IllegalArgumentException.class,
+ () -> element.getJsInvoker(NotPublicJs.class));
+
+ assertTrue(exception.getMessage().contains("public"),
+ "the message should say what stops the method from running: "
+ + exception.getMessage());
+ }
+
+ @Test
+ void getJsInvoker_defaultMethodDeclaringJavaScript_throws() {
+ Element element = ElementFactory.createDiv();
+
+ assertThrows(IllegalArgumentException.class,
+ () -> element.getJsInvoker(ContradictoryJs.class),
+ "a method can run in Java or in the browser, not both");
+ }
+
@Test
void getJsInvoker_defaultMethod_runsInJavaAndSchedulesWhatItCalls() {
UI ui = new MockUI();
@@ -2782,7 +2822,7 @@ interface UndeclaredJs extends Serializable {
}
@JsInvoker
- interface ComposingJs extends Serializable {
+ public interface ComposingJs extends Serializable {
@JsExpression("this.method($0)")
void method(String value);
@@ -2790,6 +2830,32 @@ default void twice(String value) {
method(value);
method(value);
}
+
+ default String describe() {
+ return "composing";
+ }
+
+ static String name() {
+ return "ComposingJs";
+ }
+ }
+
+ @JsInvoker
+ interface NotPublicJs extends Serializable {
+ @JsExpression("this.method()")
+ void method();
+
+ default void twice() {
+ method();
+ method();
+ }
+ }
+
+ @JsInvoker
+ interface ContradictoryJs extends Serializable {
+ @JsExpression("this.method()")
+ default void method() {
+ }
}
@JsInvoker
From d6911db6750c3e6f120c44a6289e2be561a74663 Mon Sep 17 00:00:00 2001
From: "totally-not-ai[bot]"
<290682512+totally-not-ai[bot]@users.noreply.github.com>
Date: Fri, 18 Sep 2026 19:31:11 +0000
Subject: [PATCH 24/57] chore: keep the merge free of an unrelated header
change
The year in the copyright header of a CDI test file was rewritten while
the merge was committed. It belongs to neither side of the merge and to
nothing in this branch, so it goes back to what it was.
---
.../src/test/java/com/vaadin/cdi/itest/AbstractCdiTest.java | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/flow-tests/vaadin-cdi-tests/src/test/java/com/vaadin/cdi/itest/AbstractCdiTest.java b/flow-tests/vaadin-cdi-tests/src/test/java/com/vaadin/cdi/itest/AbstractCdiTest.java
index 968a10b06fb..dbe757c4ba5 100644
--- a/flow-tests/vaadin-cdi-tests/src/test/java/com/vaadin/cdi/itest/AbstractCdiTest.java
+++ b/flow-tests/vaadin-cdi-tests/src/test/java/com/vaadin/cdi/itest/AbstractCdiTest.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2000-2026 Vaadin Ltd.
+ * Copyright 2000-2018 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
From c7528d76b498da3241d23d8b6a062487df959072 Mon Sep 17 00:00:00 2001
From: "totally-not-ai[bot]"
<290682512+totally-not-ai[bot]@users.noreply.github.com>
Date: Sat, 19 Sep 2026 04:58:58 +0000
Subject: [PATCH 25/57] refactor: write the invoker file the way a generated
file is written
The hotswap path built the content and wrote it itself. It now goes
through an entry point on the task that generates the file, with the
interfaces it has to hold passed in and no class finder to scan for them,
and writes through the same call a build uses: the file is left alone
when its content would not change, and written atomically otherwise, so
the dev server never reads one that is half written.
The bundle check drops a branch of its own as well, since a hash that is
missing is already content the bundle does not carry, and the client says
why a call that runs a function of the bundle has no use for the node
parameters an expression runs with.
---
.../server/frontend/BundleValidationUtil.java | 14 ++------
.../frontend/TaskGenerateJsInvokers.java | 32 +++++++++++++++++++
.../client/flow/ExecuteJavaScriptProcessor.ts | 5 ++-
.../hotswap/impl/JsInvokerHotswapper.java | 29 +++++++++++------
.../hotswap/impl/JsInvokerHotswapperTest.java | 6 ++++
5 files changed, 65 insertions(+), 21 deletions(-)
diff --git a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/BundleValidationUtil.java b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/BundleValidationUtil.java
index 745a64682c7..efb672f8866 100644
--- a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/BundleValidationUtil.java
+++ b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/BundleValidationUtil.java
@@ -1019,22 +1019,14 @@ private static boolean jsInvokersChanged(Options options,
+ FrontendUtils.JS_INVOKERS_FILE_NAME;
String content = new TaskGenerateJsInvokers(options).getFileContent();
- if (!frontendHashes.has(jsInvokersPath)) {
- // Every build that knows about invoker interfaces records what it
- // generated for them, so a bundle without that is one built before
- // they existed: it carries none of their JavaScript, and a call
- // made through an invoker would find nothing to run.
- getLogger().info(
- "Detected a bundle that was built without the JavaScript declared by the invoker interfaces");
- return true;
- }
-
List faultyContent = new ArrayList<>();
compareFrontendHashes(frontendHashes, faultyContent, jsInvokersPath,
content);
if (!faultyContent.isEmpty()) {
+ // Either the declarations changed, or the bundle was built before
+ // they existed and carries none of their JavaScript
getLogger().info(
- "Detected changed JavaScript declared by the invoker interfaces");
+ "Detected JavaScript declared by the invoker interfaces that the bundle does not carry");
return true;
}
return false;
diff --git a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokers.java b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokers.java
index a73156599f6..b069eebfa9b 100644
--- a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokers.java
+++ b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokers.java
@@ -16,6 +16,8 @@
package com.vaadin.flow.server.frontend;
import java.io.File;
+import java.io.IOException;
+import java.io.UncheckedIOException;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Collection;
@@ -102,6 +104,36 @@ public static String renderFileContent(Collection> invokers) {
return String.join(System.lineSeparator(), lines);
}
+ /**
+ * Writes the file that registers the JavaScript of the given invoker
+ * interfaces, for a caller that has to write it again while the application
+ * runs rather than as part of a build.
+ *
+ * Goes through the same write as {@link #execute()}, which leaves the file
+ * alone when its content would not change and writes it atomically
+ * otherwise, so the dev server is not told about an update that is not one
+ * and never reads a file that is half written.
+ *
+ * @param options
+ * where the file belongs, not null
+ * @param invokers
+ * the invoker interfaces the file registers, not
+ * null
+ * @return the content the file holds afterwards
+ */
+ public static String writeJsInvokers(Options options,
+ Collection> invokers) {
+ TaskGenerateJsInvokers task = new TaskGenerateJsInvokers(options);
+ String content = renderFileContent(invokers);
+ try {
+ task.writeIfChanged(task.getGeneratedFile(), content);
+ } catch (IOException e) {
+ throw new UncheckedIOException(
+ "Error writing " + task.getGeneratedFile(), e);
+ }
+ return content;
+ }
+
/**
* Reads back the names of the invoker interfaces a generated file
* registers, which is what a browser that has the file can run.
diff --git a/flow-client/src/main/frontend/internal/client/flow/ExecuteJavaScriptProcessor.ts b/flow-client/src/main/frontend/internal/client/flow/ExecuteJavaScriptProcessor.ts
index 4def3338a4c..8b69a1a90b5 100644
--- a/flow-client/src/main/frontend/internal/client/flow/ExecuteJavaScriptProcessor.ts
+++ b/flow-client/src/main/frontend/internal/client/flow/ExecuteJavaScriptProcessor.ts
@@ -158,7 +158,10 @@ export class ExecuteJavaScriptProcessor {
const target = invocation[invocation.length - 1];
if (typeof target === 'object' && target !== null) {
// A JS invoker call: the bundle has the function, the server sent only
- // which one to run.
+ // which one to run. The node parameters are for the context object an
+ // expression runs against, whose `getNode` maps an element back to its
+ // state node; a declared function runs against the element itself and
+ // has no context, so there is nothing that could ask.
this.invokeFromBundle(target as JsInvokerTarget, parameters);
return;
}
diff --git a/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapper.java b/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapper.java
index 28181c9d6ea..f95a72bc94d 100644
--- a/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapper.java
+++ b/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapper.java
@@ -29,11 +29,13 @@
import com.vaadin.base.devserver.hotswap.HotswapClassEvent;
import com.vaadin.base.devserver.hotswap.VaadinHotswapper;
+import com.vaadin.flow.di.Lookup;
import com.vaadin.flow.internal.FrontendUtils;
import com.vaadin.flow.js.JsExpression;
import com.vaadin.flow.js.JsInvoker;
import com.vaadin.flow.server.Mode;
import com.vaadin.flow.server.VaadinService;
+import com.vaadin.flow.server.frontend.Options;
import com.vaadin.flow.server.frontend.TaskGenerateJsInvokers;
import com.vaadin.flow.server.startup.ApplicationConfiguration;
@@ -119,21 +121,30 @@ private static String hotApply(VaadinService service, File generatedFile,
return generated;
}
try {
- String content = TaskGenerateJsInvokers.renderFileContent(
+ // Written by the task that generates it during a build, with the
+ // interfaces it has to hold passed in: the changed classes are at
+ // hand here, so nothing has to scan the class path for them
+ return TaskGenerateJsInvokers.writeJsInvokers(
+ buildOptions(service, configuration),
invokersToRender(generated, changedInvokers));
- if (content.equals(generated)) {
- return generated;
- }
- Files.createDirectories(generatedFile.toPath().getParent());
- Files.writeString(generatedFile.toPath(), content,
- StandardCharsets.UTF_8);
- return content;
- } catch (IOException | RuntimeException e) {
+ } catch (RuntimeException e) {
getLogger().debug("Could not write {}", generatedFile, e);
return generated;
}
}
+ /**
+ * The least an invoker file needs to be written: where the project is and
+ * where its frontend folder is. No class finder, since what the file has to
+ * hold is passed in rather than scanned for.
+ */
+ private static Options buildOptions(VaadinService service,
+ ApplicationConfiguration configuration) {
+ return new Options(service.getContext().getAttribute(Lookup.class),
+ null, configuration.getProjectFolder())
+ .withFrontendDirectory(configuration.getFrontendFolder());
+ }
+
/**
* The interfaces the file has to hold: the ones it holds already, since
* those are what the browser can run and none of them changed, plus the
diff --git a/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapperTest.java b/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapperTest.java
index 2ee6cc79e71..70d365144ca 100644
--- a/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapperTest.java
+++ b/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapperTest.java
@@ -91,6 +91,12 @@ void setUp() {
.getProjectFrontendDir(deploymentConfiguration);
configuration = Mockito.mock(ApplicationConfiguration.class);
+ // Where the file is written is asked of the configuration, the same
+ // way a build asks for it
+ Mockito.when(configuration.getProjectFolder())
+ .thenReturn(projectFolder);
+ Mockito.when(configuration.getFrontendFolder())
+ .thenReturn(frontendFolder);
// What a browser runs without the frontend dev server is a bundle,
// which a case has to opt out of to get the file written again.
Mockito.when(configuration.getMode())
From c5ca9fa4b03013387d313f1972554ae6b7117773 Mon Sep 17 00:00:00 2001
From: "totally-not-ai[bot]"
<290682512+totally-not-ai[bot]@users.noreply.github.com>
Date: Sat, 19 Sep 2026 04:59:28 +0000
Subject: [PATCH 26/57] test: say that the hash a bundle was built with is
arbitrary here
Hashing a plausible looking snippet read as though the bundle had been
built with exactly that. What matters is only that the hash is not the
one the declarations produce.
---
.../vaadin/flow/server/frontend/BundleValidationTest.java | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/BundleValidationTest.java b/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/BundleValidationTest.java
index 19cba05eb39..4cc6f7f75da 100644
--- a/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/BundleValidationTest.java
+++ b/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/BundleValidationTest.java
@@ -1095,10 +1095,11 @@ void jsInvokerJavaScriptChanged_bundleRebuild(Mode mode) {
setupMode(mode);
ObjectNode stats = getBasicStats();
+ // Any hash the declarations do not produce: what the bundle was built
+ // with is whatever it was, and the point is that it is not this
((ObjectNode) stats.get(FRONTEND_HASHES)).put(
FrontendUtils.GENERATED + FrontendUtils.JS_INVOKERS_FILE_NAME,
- BundleValidationUtil
- .calculateHash("window.Vaadin.Flow.jsInvokers = {};"));
+ "not the hash of what the interfaces declare");
setupFrontendUtilsMock(stats);
boolean needsBuild = BundleValidationUtil.needsBuild(options,
From cc1054337031d4b738c33608973bef1c1ba16fb2 Mon Sep 17 00:00:00 2001
From: "totally-not-ai[bot]"
<290682512+totally-not-ai[bot]@users.noreply.github.com>
Date: Sat, 19 Sep 2026 05:05:39 +0000
Subject: [PATCH 27/57] fix: ask one configuration where the invoker file is
Reading it resolved the folder from the deployment configuration and
writing it from the application configuration, which is two answers to
one question. Both go through the deployment configuration now, and the
application configuration is left with what it alone knows, the mode.
---
.../devserver/hotswap/impl/JsInvokerHotswapper.java | 13 +++++++------
.../hotswap/impl/JsInvokerHotswapperTest.java | 6 ------
2 files changed, 7 insertions(+), 12 deletions(-)
diff --git a/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapper.java b/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapper.java
index f95a72bc94d..43123cfe323 100644
--- a/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapper.java
+++ b/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapper.java
@@ -33,6 +33,7 @@
import com.vaadin.flow.internal.FrontendUtils;
import com.vaadin.flow.js.JsExpression;
import com.vaadin.flow.js.JsInvoker;
+import com.vaadin.flow.server.AbstractConfiguration;
import com.vaadin.flow.server.Mode;
import com.vaadin.flow.server.VaadinService;
import com.vaadin.flow.server.frontend.Options;
@@ -124,8 +125,7 @@ private static String hotApply(VaadinService service, File generatedFile,
// Written by the task that generates it during a build, with the
// interfaces it has to hold passed in: the changed classes are at
// hand here, so nothing has to scan the class path for them
- return TaskGenerateJsInvokers.writeJsInvokers(
- buildOptions(service, configuration),
+ return TaskGenerateJsInvokers.writeJsInvokers(buildOptions(service),
invokersToRender(generated, changedInvokers));
} catch (RuntimeException e) {
getLogger().debug("Could not write {}", generatedFile, e);
@@ -138,11 +138,12 @@ private static String hotApply(VaadinService service, File generatedFile,
* where its frontend folder is. No class finder, since what the file has to
* hold is passed in rather than scanned for.
*/
- private static Options buildOptions(VaadinService service,
- ApplicationConfiguration configuration) {
+ private static Options buildOptions(VaadinService service) {
+ AbstractConfiguration configuration = service
+ .getDeploymentConfiguration();
return new Options(service.getContext().getAttribute(Lookup.class),
- null, configuration.getProjectFolder())
- .withFrontendDirectory(configuration.getFrontendFolder());
+ null, configuration.getProjectFolder()).withFrontendDirectory(
+ FrontendUtils.getProjectFrontendDir(configuration));
}
/**
diff --git a/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapperTest.java b/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapperTest.java
index 70d365144ca..2ee6cc79e71 100644
--- a/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapperTest.java
+++ b/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapperTest.java
@@ -91,12 +91,6 @@ void setUp() {
.getProjectFrontendDir(deploymentConfiguration);
configuration = Mockito.mock(ApplicationConfiguration.class);
- // Where the file is written is asked of the configuration, the same
- // way a build asks for it
- Mockito.when(configuration.getProjectFolder())
- .thenReturn(projectFolder);
- Mockito.when(configuration.getFrontendFolder())
- .thenReturn(frontendFolder);
// What a browser runs without the frontend dev server is a bundle,
// which a case has to opt out of to get the file written again.
Mockito.when(configuration.getMode())
From 3aeb6cdd0e4171222c11f4e569944b3e9abf8d6c Mon Sep 17 00:00:00 2001
From: "totally-not-ai[bot]"
<290682512+totally-not-ai[bot]@users.noreply.github.com>
Date: Sat, 19 Sep 2026 10:02:20 +0000
Subject: [PATCH 28/57] test: pin the shape an application declares JavaScript
in
An interface of its own, a primitive argument and an expression that
reads it inside an object literal: the shape the API is described in
outside this repository, which nothing here was exercising.
---
.../java/com/vaadin/flow/dom/ElementTest.java | 27 +++++++++++++++++++
1 file changed, 27 insertions(+)
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 d73c10eb466..af8eee9d75d 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
@@ -2738,6 +2738,27 @@ void getJsInvoker_methodWithAnotherReturnType_throws() {
+ exception.getMessage());
}
+ @Test
+ void getJsInvoker_primitiveArgument_isSentAsTheArgumentOfTheCall() {
+ UI ui = new MockUI();
+ Element element = ElementFactory.createDiv();
+ ui.getElement().appendChild(element);
+
+ element.getJsInvoker(ScrollJs.class).scrollTo(320);
+ ui.getInternals().getStateTree().runExecutionsBeforeClientResponse();
+
+ List pendingJs = ui.getInternals()
+ .dumpPendingJavaScriptInvocations();
+ assertEquals(1, pendingJs.size());
+ JavaScriptInvocation invocation = pendingJs.get(0).getInvocation();
+
+ assertEquals(
+ new JsInvokerCall(ScrollJs.class, "scrollTo", List.of(320)),
+ invocation.getInvokerCall());
+ assertEquals(List.of(320, element), invocation.getParameters(),
+ "the argument should reach the client as what it is");
+ }
+
@Test
void getJsInvoker_methodWithoutDeclaredJavaScript_throws() {
Element element = ElementFactory.createDiv();
@@ -2816,6 +2837,12 @@ interface UnsupportedJs extends Serializable {
String readValue();
}
+ @JsInvoker
+ public interface ScrollJs extends Serializable {
+ @JsExpression("this.scrollTo({ top: $0, behavior: 'smooth' })")
+ void scrollTo(int top);
+ }
+
@JsInvoker
interface UndeclaredJs extends Serializable {
void undeclared();
From 75728042e64860eff8d6291e24cbaf5b16a4545f Mon Sep 17 00:00:00 2001
From: "totally-not-ai[bot]"
<290682512+totally-not-ai[bot]@users.noreply.github.com>
Date: Sat, 19 Sep 2026 10:06:06 +0000
Subject: [PATCH 29/57] test: pin a declaration where its text could be mangled
Scheduling a call with an argument is covered already, and repeating it
with another argument type pinned the language rather than anything
here. What was worth pinning is that a declaration reaches the bundle as
it was written, braces and all, so the rendered one says that.
---
.../frontend/TaskGenerateJsInvokersTest.java | 8 +++---
.../java/com/vaadin/flow/dom/ElementTest.java | 27 -------------------
2 files changed, 5 insertions(+), 30 deletions(-)
diff --git a/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokersTest.java b/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokersTest.java
index 7e9360373e1..3b1246fa1f0 100644
--- a/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokersTest.java
+++ b/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokersTest.java
@@ -39,7 +39,7 @@ class TaskGenerateJsInvokersTest {
@JsInvoker
public interface GreeterJs extends Serializable {
- @JsExpression("window.alert($0)")
+ @JsExpression("window.alert({ text: $0, kind: 'greeting' })")
void showGreeting(String greeting);
@JsExpression("window.alert('Hello')")
@@ -83,8 +83,10 @@ void generatesAFunctionPerDeclaredExpression()
content.contains("\"showGreeting/1\": async function ($0) {"),
"an overload should be keyed by name and argument count: "
+ content);
- assertTrue(content.contains("window.alert($0)"),
- "the declared expression should be the body of the function: "
+ assertTrue(
+ content.contains(
+ "window.alert({ text: $0, kind: 'greeting' })"),
+ "the declared expression should be the body of the function, as it was written: "
+ content);
assertTrue(content.contains("\"showGreeting/0\": async function () {"),
"the no-argument overload should be generated too: " + content);
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 af8eee9d75d..d73c10eb466 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
@@ -2738,27 +2738,6 @@ void getJsInvoker_methodWithAnotherReturnType_throws() {
+ exception.getMessage());
}
- @Test
- void getJsInvoker_primitiveArgument_isSentAsTheArgumentOfTheCall() {
- UI ui = new MockUI();
- Element element = ElementFactory.createDiv();
- ui.getElement().appendChild(element);
-
- element.getJsInvoker(ScrollJs.class).scrollTo(320);
- ui.getInternals().getStateTree().runExecutionsBeforeClientResponse();
-
- List pendingJs = ui.getInternals()
- .dumpPendingJavaScriptInvocations();
- assertEquals(1, pendingJs.size());
- JavaScriptInvocation invocation = pendingJs.get(0).getInvocation();
-
- assertEquals(
- new JsInvokerCall(ScrollJs.class, "scrollTo", List.of(320)),
- invocation.getInvokerCall());
- assertEquals(List.of(320, element), invocation.getParameters(),
- "the argument should reach the client as what it is");
- }
-
@Test
void getJsInvoker_methodWithoutDeclaredJavaScript_throws() {
Element element = ElementFactory.createDiv();
@@ -2837,12 +2816,6 @@ interface UnsupportedJs extends Serializable {
String readValue();
}
- @JsInvoker
- public interface ScrollJs extends Serializable {
- @JsExpression("this.scrollTo({ top: $0, behavior: 'smooth' })")
- void scrollTo(int top);
- }
-
@JsInvoker
interface UndeclaredJs extends Serializable {
void undeclared();
From c64dc258a75f93bf378d56335a206aa5c69eaa43 Mon Sep 17 00:00:00 2001
From: "totally-not-ai[bot]"
<290682512+totally-not-ai[bot]@users.noreply.github.com>
Date: Sat, 19 Sep 2026 18:42:19 +0000
Subject: [PATCH 30/57] docs: point between the two ways of running JavaScript
on an element
Someone reaching for executeJs has no reason to know the declared way
exists, which is where the discoverability of it is decided. Its javadoc
now says what sending an expression costs and where the alternative is,
and the three methods link to each other.
---
flow-server/src/main/java/com/vaadin/flow/dom/Element.java | 7 +++++++
1 file changed, 7 insertions(+)
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 5d5979666d5..8afb750da66 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
@@ -1842,6 +1842,7 @@ public T as(Class componentType) {
* null if not attached).
* @return a pending result that can be used to get a return value from the
* execution
+ * @see #getJsInvoker(Class)
* @since 25.0
*/
public PendingJavaScriptResult callJsFunction(String functionName,
@@ -1926,6 +1927,11 @@ public PendingJavaScriptResult callJsFunction(String functionName,
*
* If the element is not attached or not visible, the function call will be
* deferred until the element is attached and visible.
+ *
+ * The expression is sent to the browser and compiled there, which a content
+ * security policy without unsafe-eval does not allow.
+ * {@link #getJsInvoker(Class)} runs JavaScript that is declared in Java and
+ * collected into the bundle instead, and sends no expression.
*
* @param expression
* the JavaScript expression to invoke
@@ -1933,6 +1939,7 @@ public PendingJavaScriptResult callJsFunction(String functionName,
* parameters to pass to the expression
* @return a pending result that can be used to get a value returned from
* the expression
+ * @see #getJsInvoker(Class)
* @since 25.0
*/
public PendingJavaScriptResult executeJs(String expression,
From b8289a3c8bf2924264065709d1730232a22bcfa2 Mon Sep 17 00:00:00 2001
From: "totally-not-ai[bot]"
<290682512+totally-not-ai[bot]@users.noreply.github.com>
Date: Sat, 19 Sep 2026 18:43:46 +0000
Subject: [PATCH 31/57] docs: say that calling a function sends an expression
too
It builds one and hands it over the same way, so a policy without
unsafe-eval stops it as well. Only executeJs said so, which left the
pointer next to it reading as though calling a function were the way
around that.
---
flow-server/src/main/java/com/vaadin/flow/dom/Element.java | 5 +++++
1 file changed, 5 insertions(+)
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 8afb750da66..f46bb8edd4d 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
@@ -1830,6 +1830,11 @@ public T as(Class componentType) {
*
* If the element is not attached or not visible, the function call will be
* deferred until the element is attached and visible.
+ *
+ * The call is sent to the browser as an expression and compiled there,
+ * which a content security policy without unsafe-eval does not
+ * allow. {@link #getJsInvoker(Class)} runs JavaScript that is declared in
+ * Java and collected into the bundle instead, and sends no expression.
*
* @param functionName
* the name of the function to call, may contain dots to indicate
From 8d06def3f6b0a06a274796a1959e4524c54e7e57 Mon Sep 17 00:00:00 2001
From: "totally-not-ai[bot]"
<290682512+totally-not-ai[bot]@users.noreply.github.com>
Date: Sat, 19 Sep 2026 18:52:05 +0000
Subject: [PATCH 32/57] refactor: let the task own its file, and the hotswapper
own the decisions
Reading the generated file, recognising what it carries, keeping the
interfaces it already names and writing it again were all spread over the
hotswapper, which meant the format was known in two places. The task that
generates the file answers both questions now - which of these interfaces
is it missing, and write it so that it is not - and the hotswapper is
left with what only it knows: which classes changed, whether a dev server
can replace the module, and what to say when nothing can.
---
.../frontend/TaskGenerateJsInvokers.java | 125 ++++++++++++--
.../frontend/TaskGenerateJsInvokersTest.java | 31 +++-
.../hotswap/impl/JsInvokerHotswapper.java | 153 +++---------------
3 files changed, 164 insertions(+), 145 deletions(-)
diff --git a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokers.java b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokers.java
index b069eebfa9b..022c0ab8bcf 100644
--- a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokers.java
+++ b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokers.java
@@ -17,16 +17,22 @@
import java.io.File;
import java.io.IOException;
-import java.io.UncheckedIOException;
import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
+import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.IntStream;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
import com.vaadin.flow.internal.FrontendUtils;
import com.vaadin.flow.js.JsExpression;
import com.vaadin.flow.js.JsInvoker;
@@ -105,33 +111,124 @@ public static String renderFileContent(Collection> invokers) {
}
/**
- * Writes the file that registers the JavaScript of the given invoker
- * interfaces, for a caller that has to write it again while the application
- * runs rather than as part of a build.
+ * The invoker interfaces of the given ones whose JavaScript the generated
+ * file does not carry, which is the JavaScript a browser can run of them.
+ *
+ * @param options
+ * where the file is, not null
+ * @param invokers
+ * the invoker interfaces to look for, not null
+ * @return those the file does not carry, empty when it carries all of them
+ */
+ public static List> missingFromGeneratedFile(Options options,
+ Collection> invokers) {
+ String generated = readGeneratedFile(options);
+ return invokers.stream()
+ .filter(invoker -> !isInGeneratedFile(invoker, generated))
+ .toList();
+ }
+
+ /**
+ * Writes the generated file again so that it carries what the given invoker
+ * interfaces declare, for a caller that has to update it while the
+ * application runs rather than as part of a build.
+ *
+ * The interfaces the file already registers are kept: they are what a
+ * browser that has the file can run, and the caller only knows about the
+ * ones it passes in. One the file registers and the application no longer
+ * has is dropped, and one whose annotation was removed keeps its functions
+ * with nothing calling them, until a build renders the file again.
*
* Goes through the same write as {@link #execute()}, which leaves the file
* alone when its content would not change and writes it atomically
- * otherwise, so the dev server is not told about an update that is not one
+ * otherwise, so a dev server is not told about an update that is not one
* and never reads a file that is half written.
*
* @param options
- * where the file belongs, not null
+ * where the file is, not null
* @param invokers
- * the invoker interfaces the file registers, not
- * null
- * @return the content the file holds afterwards
+ * the invoker interfaces to write it for, not null
+ * and not empty
+ * @return those of them the file does not carry afterwards, empty when it
+ * carries all of them
*/
- public static String writeJsInvokers(Options options,
+ public static List> updateJsInvokers(Options options,
Collection> invokers) {
+ String generated = readGeneratedFile(options);
+ String content = renderFileContent(withInvokersOf(generated, invokers));
+
TaskGenerateJsInvokers task = new TaskGenerateJsInvokers(options);
- String content = renderFileContent(invokers);
try {
task.writeIfChanged(task.getGeneratedFile(), content);
} catch (IOException e) {
- throw new UncheckedIOException(
- "Error writing " + task.getGeneratedFile(), e);
+ getLogger().debug("Could not write {}", task.getGeneratedFile(), e);
+ return List.copyOf(invokers);
+ }
+ return invokers.stream()
+ .filter(invoker -> !isInGeneratedFile(invoker, content))
+ .toList();
+ }
+
+ /**
+ * Whether the given content carries what the invoker declares, compared as
+ * this class renders it, so the interface name, the methods, their argument
+ * counts and the JavaScript all have to match. A method that was removed
+ * does not show up as a difference: its function stays in the file with
+ * nothing calling it.
+ */
+ private static boolean isInGeneratedFile(Class> invoker,
+ String generated) {
+ if (generated == null) {
+ return false;
+ }
+ List declared = renderInvokerLines(invoker);
+ if (declared.isEmpty()) {
+ // Declares no JavaScript, so there is nothing to carry
+ return true;
}
- return content;
+ return generated
+ .contains(String.join(System.lineSeparator(), declared));
+ }
+
+ /**
+ * The given interfaces, plus the ones the content registers that are not
+ * among them and can still be loaded.
+ */
+ private static Collection> withInvokersOf(String generated,
+ Collection> invokers) {
+ Map> byName = new LinkedHashMap<>();
+ invokers.forEach(invoker -> byName.put(invoker.getName(), invoker));
+ ClassLoader classLoader = invokers.iterator().next().getClassLoader();
+ for (String name : readInvokerNames(generated)) {
+ if (byName.containsKey(name)) {
+ continue;
+ }
+ try {
+ byName.put(name, Class.forName(name, false, classLoader));
+ } catch (ClassNotFoundException | LinkageError e) {
+ getLogger().debug("Could not load the invoker {}", name, e);
+ }
+ }
+ return byName.values();
+ }
+
+ private static String readGeneratedFile(Options options) {
+ File generatedFile = new TaskGenerateJsInvokers(options)
+ .getGeneratedFile();
+ if (!generatedFile.exists()) {
+ return null;
+ }
+ try {
+ return Files.readString(generatedFile.toPath(),
+ StandardCharsets.UTF_8);
+ } catch (IOException e) {
+ getLogger().debug("Could not read {}", generatedFile, e);
+ return null;
+ }
+ }
+
+ private static Logger getLogger() {
+ return LoggerFactory.getLogger(TaskGenerateJsInvokers.class);
}
/**
diff --git a/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokersTest.java b/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokersTest.java
index 3b1246fa1f0..7f2a7fd90a2 100644
--- a/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokersTest.java
+++ b/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokersTest.java
@@ -16,7 +16,10 @@
package com.vaadin.flow.server.frontend;
import java.io.File;
+import java.io.IOException;
import java.io.Serializable;
+import java.nio.file.Files;
+import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
@@ -25,6 +28,7 @@
import org.mockito.Mockito;
import com.vaadin.flow.di.Lookup;
+import com.vaadin.flow.internal.FrontendUtils;
import com.vaadin.flow.js.JsExpression;
import com.vaadin.flow.js.JsInvoker;
import com.vaadin.flow.server.frontend.scanner.ClassFinder.DefaultClassFinder;
@@ -55,13 +59,14 @@ public interface NothingJs extends Serializable {
File temporaryFolder;
private TaskGenerateJsInvokers task;
+ private Options options;
private File frontendFolder;
@BeforeEach
void setUp() {
frontendFolder = new File(temporaryFolder, FRONTEND);
frontendFolder.mkdirs();
- Options options = new Options(Mockito.mock(Lookup.class),
+ options = new Options(Mockito.mock(Lookup.class),
new DefaultClassFinder(
Set.of(GreeterJs.class, NothingJs.class)),
null).withFrontendDirectory(frontendFolder);
@@ -103,6 +108,30 @@ void invokerWithoutDeclaredJavaScript_isNotRegistered()
+ content);
}
+ @Test
+ void updateJsInvokers_dropsAnInvokerTheFileNamesAndNothingHas()
+ throws ExecutionFailedException, IOException {
+ task.execute();
+ // What a file written by an older state of the application looks like:
+ // it names an interface that is no longer there to render
+ File generated = new File(
+ FrontendUtils.getFrontendGeneratedFolder(frontendFolder),
+ FrontendUtils.JS_INVOKERS_FILE_NAME);
+ Files.writeString(generated.toPath(),
+ Files.readString(generated.toPath()).replace(
+ NothingJs.class.getName(), "com.example.GoneJs"));
+
+ List> missing = TaskGenerateJsInvokers
+ .updateJsInvokers(options, List.of(GreeterJs.class));
+
+ assertTrue(missing.isEmpty(),
+ "the interface that was asked for should be in the file");
+ assertFalse(
+ Files.readString(generated.toPath())
+ .contains("com.example.GoneJs"),
+ "a name the file holds that nothing answers to should be dropped");
+ }
+
@Test
void writesTheFileTheBootstrapImports() throws ExecutionFailedException {
task.execute();
diff --git a/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapper.java b/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapper.java
index 43123cfe323..5be9f3a74ee 100644
--- a/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapper.java
+++ b/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapper.java
@@ -15,14 +15,7 @@
*/
package com.vaadin.base.devserver.hotswap.impl;
-import java.io.File;
-import java.io.IOException;
-import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
-import java.util.Collection;
-import java.util.LinkedHashMap;
import java.util.List;
-import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -77,66 +70,42 @@ public void onClassesChange(HotswapClassEvent event) {
}
VaadinService service = event.getVaadinService();
- File generatedFile = generatedInvokersFile(service);
- String generated = readGeneratedInvokers(generatedFile);
-
- List> stale = invokers.stream()
- .filter(invoker -> !isInBundle(invoker, generated)).toList();
+ Options options = buildOptions(service);
+ List> stale = TaskGenerateJsInvokers
+ .missingFromGeneratedFile(options, invokers);
if (stale.isEmpty()) {
return;
}
- String applied = hotApply(service, generatedFile, generated, invokers);
- // What the file holds now is what a browser can run, so anything the
- // rendering did not cover is still a change nobody can apply
- List unresolved = stale.stream()
- .filter(invoker -> !isInBundle(invoker, applied))
- .map(Class::getName).toList();
+ if (!canReplaceInTheBrowser(service)) {
+ // What a browser has without the dev server is a bundle, which
+ // only a build produces
+ report(names(stale));
+ return;
+ }
+
+ List> unresolved = TaskGenerateJsInvokers
+ .updateJsInvokers(options, invokers);
if (unresolved.isEmpty()) {
getLogger().debug(
- "Wrote the JavaScript declared by {} to {}, which the frontend dev server replaces in the browser",
- stale.stream().map(Class::getName).toList(), generatedFile);
+ "Wrote the JavaScript declared by {}, which the frontend dev server replaces in the browser",
+ names(stale));
} else {
- report(unresolved);
+ report(names(unresolved));
}
}
- /**
- * Writes the generated file again from what the invoker interfaces declare,
- * so the frontend dev server can replace the module in the browser.
- *
- * Only with the dev server running: what a browser has without it is a
- * bundle, which this can not replace. The file is left alone when its
- * content would not change, so the dev server is not told about an update
- * that is not one.
- *
- * @return the content the file holds afterwards, which is the content it
- * held already when nothing could be written
- */
- private static String hotApply(VaadinService service, File generatedFile,
- String generated, List> changedInvokers) {
+ private static boolean canReplaceInTheBrowser(VaadinService service) {
ApplicationConfiguration configuration = ApplicationConfiguration
.get(service.getContext());
- if (generatedFile == null || configuration == null || configuration
- .getMode() != Mode.DEVELOPMENT_FRONTEND_LIVERELOAD) {
- return generated;
- }
- try {
- // Written by the task that generates it during a build, with the
- // interfaces it has to hold passed in: the changed classes are at
- // hand here, so nothing has to scan the class path for them
- return TaskGenerateJsInvokers.writeJsInvokers(buildOptions(service),
- invokersToRender(generated, changedInvokers));
- } catch (RuntimeException e) {
- getLogger().debug("Could not write {}", generatedFile, e);
- return generated;
- }
+ return configuration != null && configuration
+ .getMode() == Mode.DEVELOPMENT_FRONTEND_LIVERELOAD;
}
/**
- * The least an invoker file needs to be written: where the project is and
- * where its frontend folder is. No class finder, since what the file has to
- * hold is passed in rather than scanned for.
+ * The least the generated file needs to be read and written: where the
+ * project is and where its frontend folder is. No class finder, since what
+ * the file has to hold is passed in rather than scanned for.
*/
private static Options buildOptions(VaadinService service) {
AbstractConfiguration configuration = service
@@ -146,33 +115,8 @@ private static Options buildOptions(VaadinService service) {
FrontendUtils.getProjectFrontendDir(configuration));
}
- /**
- * The interfaces the file has to hold: the ones it holds already, since
- * those are what the browser can run and none of them changed, plus the
- * ones that just changed - which is also how an interface that was only now
- * annotated gets in, without anything having scanned for it.
- *
- * An interface the file holds and the application no longer has is left
- * out, and one whose annotation was removed keeps its functions in the file
- * with nothing calling them, until a build renders it again.
- */
- private static Collection> invokersToRender(String generated,
- List> changedInvokers) {
- Map> byName = new LinkedHashMap<>();
- changedInvokers
- .forEach(invoker -> byName.put(invoker.getName(), invoker));
- ClassLoader classLoader = changedInvokers.get(0).getClassLoader();
- for (String name : TaskGenerateJsInvokers.readInvokerNames(generated)) {
- if (byName.containsKey(name)) {
- continue;
- }
- try {
- byName.put(name, Class.forName(name, false, classLoader));
- } catch (ClassNotFoundException | LinkageError e) {
- getLogger().debug("Could not load the invoker {}", name, e);
- }
- }
- return byName.values();
+ private static List names(List> invokers) {
+ return invokers.stream().map(Class::getName).toList();
}
/**
@@ -190,57 +134,6 @@ void report(List invokerNames) {
String.join(", ", invokerNames));
}
- /**
- * Whether the generated file carries what the invoker declares, compared as
- * the build renders it. A method that was removed does not show up as a
- * difference: its function stays in the bundle with nothing calling it.
- */
- private static boolean isInBundle(Class> invoker, String generated) {
- if (generated == null) {
- // Nothing carries the declarations, so nothing matches them
- return false;
- }
- List declared = TaskGenerateJsInvokers
- .renderInvokerLines(invoker);
- if (declared.isEmpty()) {
- // Declares no JavaScript, so there is nothing to carry
- return true;
- }
- return generated
- .contains(String.join(System.lineSeparator(), declared));
- }
-
- private static File generatedInvokersFile(VaadinService service) {
- File frontendFolder = FrontendUtils
- .getProjectFrontendDir(service.getDeploymentConfiguration());
- if (frontendFolder == null) {
- return null;
- }
- return new File(
- FrontendUtils.getFrontendGeneratedFolder(frontendFolder),
- FrontendUtils.JS_INVOKERS_FILE_NAME);
- }
-
- /**
- * Reads the generated file from the frontend folder, which is the file the
- * dev server reads and this class writes, so what is compared and what is
- * written are the same bytes. Fetching it from the dev server instead would
- * answer with the module as it transforms it, which is not what a
- * declaration renders to.
- */
- private static String readGeneratedInvokers(File generatedFile) {
- if (generatedFile == null || !generatedFile.exists()) {
- return null;
- }
- try {
- return Files.readString(generatedFile.toPath(),
- StandardCharsets.UTF_8);
- } catch (IOException e) {
- getLogger().debug("Could not read {}", generatedFile, e);
- return null;
- }
- }
-
private static Logger getLogger() {
return LoggerFactory.getLogger(JsInvokerHotswapper.class);
}
From a4ac4a3a02cca4c79be879801bca668b0dc58389 Mon Sep 17 00:00:00 2001
From: "totally-not-ai[bot]"
<290682512+totally-not-ai[bot]@users.noreply.github.com>
Date: Sat, 19 Sep 2026 18:58:06 +0000
Subject: [PATCH 33/57] fix: report only what the file is missing when it
cannot be written
A write that fails leaves the file as it was, so what it was already
carrying is still carried. Answering with every interface that was asked
for made a recompile that redefines a batch of them warn about the ones
that were never a problem.
On the other side nothing can be missing: what is written is rendered
from the interfaces that were asked for, so the check after it could
only ever answer the same way.
---
.../frontend/TaskGenerateJsInvokers.java | 11 ++++---
.../frontend/TaskGenerateJsInvokersTest.java | 30 +++++++++++++++++++
2 files changed, 37 insertions(+), 4 deletions(-)
diff --git a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokers.java b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokers.java
index 022c0ab8bcf..1da4c31cacf 100644
--- a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokers.java
+++ b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokers.java
@@ -162,11 +162,14 @@ public static List> updateJsInvokers(Options options,
task.writeIfChanged(task.getGeneratedFile(), content);
} catch (IOException e) {
getLogger().debug("Could not write {}", task.getGeneratedFile(), e);
- return List.copyOf(invokers);
+ // The file is as it was, so only what it was already missing is
+ // missing now
+ return invokers.stream()
+ .filter(invoker -> !isInGeneratedFile(invoker, generated))
+ .toList();
}
- return invokers.stream()
- .filter(invoker -> !isInGeneratedFile(invoker, content))
- .toList();
+ // Everything asked for went into the content that was written
+ return List.of();
}
/**
diff --git a/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokersTest.java b/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokersTest.java
index 7f2a7fd90a2..d626f5ce6ee 100644
--- a/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokersTest.java
+++ b/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokersTest.java
@@ -36,8 +36,10 @@
import static com.vaadin.flow.internal.FrontendUtils.FRONTEND;
import static com.vaadin.flow.internal.FrontendUtils.GENERATED;
import static com.vaadin.flow.internal.FrontendUtils.JS_INVOKERS_FILE_NAME;
+import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
class TaskGenerateJsInvokersTest {
@@ -50,6 +52,12 @@ public interface GreeterJs extends Serializable {
void showGreeting();
}
+ @JsInvoker
+ public interface CounterJs extends Serializable {
+ @JsExpression("this.count = ($0 || 0) + 1")
+ void count(Integer from);
+ }
+
@JsInvoker
public interface NothingJs extends Serializable {
void notDeclared();
@@ -132,6 +140,28 @@ void updateJsInvokers_dropsAnInvokerTheFileNamesAndNothingHas()
"a name the file holds that nothing answers to should be dropped");
}
+ @Test
+ void updateJsInvokers_fileNotWritable_answersWithWhatItDoesNotCarry()
+ throws ExecutionFailedException {
+ // A file that carries one of the two interfaces, and a folder nothing
+ // can be written into
+ task.execute();
+ File generatedFolder = FrontendUtils
+ .getFrontendGeneratedFolder(frontendFolder);
+ assumeTrue(generatedFolder.setWritable(false),
+ "the folder has to be made read only for this");
+
+ try {
+ List> missing = TaskGenerateJsInvokers.updateJsInvokers(
+ options, List.of(GreeterJs.class, CounterJs.class));
+
+ assertEquals(List.of(CounterJs.class), missing,
+ "the interface the file carries is not missing because the write failed");
+ } finally {
+ generatedFolder.setWritable(true);
+ }
+ }
+
@Test
void writesTheFileTheBootstrapImports() throws ExecutionFailedException {
task.execute();
From 04450c54faa3676a9fd27ee6b8fb544ad4495fe7 Mon Sep 17 00:00:00 2001
From: "totally-not-ai[bot]"
<290682512+totally-not-ai[bot]@users.noreply.github.com>
Date: Sat, 19 Sep 2026 19:05:24 +0000
Subject: [PATCH 34/57] refactor: name both ways of running JavaScript
executeJs
One takes an expression, the other an interface that declares it, and
they differ in nothing else that a caller thinks about: when the
JavaScript runs, what it runs against, how a result is read. Having one
of them under a name of its own put it where nobody looking at the other
would find it.
The javadoc of each now opens by saying which version it is, and what
they share is said once.
---
.../com/vaadin/flow/component/Focusable.java | 6 +--
.../java/com/vaadin/flow/dom/Element.java | 35 +++++++++-------
.../java/com/vaadin/flow/js/JsExpression.java | 10 ++---
.../java/com/vaadin/flow/js/JsInvoker.java | 5 +--
.../com/vaadin/flow/js/JsInvokerCall.java | 2 +-
.../java/com/vaadin/flow/dom/ElementTest.java | 40 +++++++++----------
6 files changed, 52 insertions(+), 46 deletions(-)
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 8a2864551a2..37cd52f0086 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
@@ -139,7 +139,7 @@ default int getTabIndex() {
* @since 25.0
*/
default void focus(FocusOption... options) {
- getElement().getJsInvoker(FocusJs.class)
+ getElement().executeJs(FocusJs.class)
.focus(FocusOption.buildOptions(options));
}
@@ -169,7 +169,7 @@ default void focus() {
* at MDN
*/
default void blur() {
- getElement().getJsInvoker(FocusJs.class).blur();
+ getElement().executeJs(FocusJs.class).blur();
}
/**
@@ -214,7 +214,7 @@ default ShortcutRegistration addFocusShortcut(Key key,
/**
* The client-side operations behind {@link Focusable}, as an invoker
- * interface for {@link Element#getJsInvoker(Class)}.
+ * interface for {@link Element#executeJs(Class)}.
*
* Focus and blur are marked as server-initiated for the client, so that the
* resulting event reports {@code isFromClient() == false}. A driver of the
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 f46bb8edd4d..2af8bc7e4f7 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
@@ -1833,8 +1833,8 @@ public T as(Class componentType) {
*
* The call is sent to the browser as an expression and compiled there,
* which a content security policy without unsafe-eval does not
- * allow. {@link #getJsInvoker(Class)} runs JavaScript that is declared in
- * Java and collected into the bundle instead, and sends no expression.
+ * allow. {@link #executeJs(Class)} runs JavaScript that is declared in Java
+ * and collected into the bundle instead, and sends no expression.
*
* @param functionName
* the name of the function to call, may contain dots to indicate
@@ -1847,7 +1847,7 @@ public T as(Class componentType) {
* null if not attached).
* @return a pending result that can be used to get a return value from the
* execution
- * @see #getJsInvoker(Class)
+ * @see #executeJs(Class)
* @since 25.0
*/
public PendingJavaScriptResult callJsFunction(String functionName,
@@ -1935,7 +1935,7 @@ public PendingJavaScriptResult callJsFunction(String functionName,
*
* The expression is sent to the browser and compiled there, which a content
* security policy without unsafe-eval does not allow.
- * {@link #getJsInvoker(Class)} runs JavaScript that is declared in Java and
+ * {@link #executeJs(Class)} runs JavaScript that is declared in Java and
* collected into the bundle instead, and sends no expression.
*
* @param expression
@@ -1944,7 +1944,7 @@ public PendingJavaScriptResult callJsFunction(String functionName,
* parameters to pass to the expression
* @return a pending result that can be used to get a value returned from
* the expression
- * @see #getJsInvoker(Class)
+ * @see #executeJs(Class)
* @since 25.0
*/
public PendingJavaScriptResult executeJs(String expression,
@@ -1953,13 +1953,15 @@ public PendingJavaScriptResult executeJs(String expression,
}
/**
- * Gets an invoker for the JavaScript expressions that the given interface
- * declares, bound to this element.
+ * Asynchronously runs the JavaScript that the given interface declares, in
+ * the browser in the context of this element.
*
- * The interface is annotated with {@link JsInvoker} and each of its methods
- * declares the JavaScript it runs with {@link JsExpression}. Calling a
- * method runs that JavaScript in the browser with the method arguments as
- * its parameters and this element as this:
+ * The version that takes an interface rather than an expression: the
+ * interface is annotated with {@link JsInvoker} and each of its methods
+ * declares the JavaScript it runs with {@link JsExpression}. This method
+ * answers with the interface, and calling a method of it runs that
+ * JavaScript with the method arguments as its parameters and this element
+ * as this:
*
*
* @JsInvoker
@@ -1968,7 +1970,7 @@ public PendingJavaScriptResult executeJs(String expression,
* void showGreeting(String greeting);
* }
*
- * element.getJsInvoker(GreeterJs.class).showGreeting("Hello");
+ * element.executeJs(GreeterJs.class).showGreeting("Hello");
*
*
* Unlike {@link #executeJs(String, Object...)}, nothing about the
@@ -1977,6 +1979,10 @@ public PendingJavaScriptResult executeJs(String expression,
* runs the collected function after looking it up by interface and method.
* No expression is sent and none is compiled in the browser, so the call
* works under a content security policy without unsafe-eval.
+ * What the two versions have in common is when the JavaScript runs - after
+ * pending DOM updates, deferred while the element is detached or invisible
+ * - and that the result of a method that declares one can be read through
+ * {@link PendingJavaScriptResult}.
*
* The scheduled invocation carries the call as a {@link JsInvokerCall}, so
* a driver of the client side that can not run JavaScript can recognize it,
@@ -1996,13 +2002,14 @@ public PendingJavaScriptResult executeJs(String expression,
* the invoker interface type
* @param invokerType
* the invoker interface, not null
- * @return an invoker bound to this element, not null
+ * @return the interface, to call the declared JavaScript through, not
+ * null
* @throws IllegalArgumentException
* if the type is not an interface, is not annotated with
* {@link JsInvoker}, or has a method the invoker can not answer
*/
@SuppressWarnings("unchecked")
- public T getJsInvoker(Class invokerType) {
+ public T executeJs(Class invokerType) {
Objects.requireNonNull(invokerType, "Invoker type cannot be null");
if (!invokerType.isInterface()) {
throw new IllegalArgumentException(
diff --git a/flow-server/src/main/java/com/vaadin/flow/js/JsExpression.java b/flow-server/src/main/java/com/vaadin/flow/js/JsExpression.java
index 629013b5823..5d230204bda 100644
--- a/flow-server/src/main/java/com/vaadin/flow/js/JsExpression.java
+++ b/flow-server/src/main/java/com/vaadin/flow/js/JsExpression.java
@@ -27,14 +27,14 @@
* The JavaScript that a method of a JS invoker interface runs, as a constant
* expression.
*
- * The annotated method is called through {@link Element#getJsInvoker(Class)}.
- * Its arguments are the parameters of the expression, referenced positionally
- * as $0, $1, …, and the element the invoker
- * was obtained from is this — the same contract as
+ * The annotated method is called through {@link Element#executeJs(Class)}. Its
+ * arguments are the parameters of the expression, referenced positionally as
+ * $0, $1, …, and the element the invoker was
+ * obtained from is this — the same contract as
* {@link Element#executeJs(String, Object...)}, except that the expression is a
* constant of the interface instead of a string built at the call site.
*
- * @see Element#getJsInvoker(Class)
+ * @see Element#executeJs(Class)
*/
@Documented
@Target(ElementType.METHOD)
diff --git a/flow-server/src/main/java/com/vaadin/flow/js/JsInvoker.java b/flow-server/src/main/java/com/vaadin/flow/js/JsInvoker.java
index 7ed2bcf8a71..66726d23681 100644
--- a/flow-server/src/main/java/com/vaadin/flow/js/JsInvoker.java
+++ b/flow-server/src/main/java/com/vaadin/flow/js/JsInvoker.java
@@ -25,8 +25,7 @@
/**
* Marks an interface whose methods declare the JavaScript they run with
- * {@link JsExpression}, to be called through
- * {@link Element#getJsInvoker(Class)}.
+ * {@link JsExpression}, to be called through {@link Element#executeJs(Class)}.
*
* The annotation is what makes the interface findable during the build: every
* annotated interface is collected into the generated bundle as a function per
@@ -36,7 +35,7 @@
* policy that does not allow unsafe-eval.
*
* @see JsExpression
- * @see Element#getJsInvoker(Class)
+ * @see Element#executeJs(Class)
*/
@Documented
@Target(ElementType.TYPE)
diff --git a/flow-server/src/main/java/com/vaadin/flow/js/JsInvokerCall.java b/flow-server/src/main/java/com/vaadin/flow/js/JsInvokerCall.java
index ffe3f010dd6..f0806a5ca6e 100644
--- a/flow-server/src/main/java/com/vaadin/flow/js/JsInvokerCall.java
+++ b/flow-server/src/main/java/com/vaadin/flow/js/JsInvokerCall.java
@@ -27,7 +27,7 @@
import com.vaadin.flow.dom.Element;
/**
- * A call made through {@link Element#getJsInvoker(Class)}: which invoker
+ * A call made through {@link Element#executeJs(Class)}: which invoker
* interface, which method of it, and the arguments that were passed.
*
* The call is what the client receives — the interface, the method and the
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 d73c10eb466..0c240d8db91 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
@@ -2666,12 +2666,12 @@ void callFunctionOnSubProperty() {
}
@Test
- void getJsInvoker_schedulesTheDeclaredExpressionAndCarriesTheCall() {
+ void executeJsWithInvoker_schedulesTheDeclaredExpressionAndCarriesTheCall() {
UI ui = new MockUI();
Element element = ElementFactory.createDiv();
ui.getElement().appendChild(element);
- element.getJsInvoker(TestJs.class).method("foo");
+ element.executeJs(TestJs.class).method("foo");
ui.getInternals().getStateTree().runExecutionsBeforeClientResponse();
List pendingJs = ui.getInternals()
@@ -2688,30 +2688,30 @@ void getJsInvoker_schedulesTheDeclaredExpressionAndCarriesTheCall() {
}
@Test
- void getJsInvoker_interfaceWithoutAnnotation_throws() {
+ void executeJsWithInvoker_interfaceWithoutAnnotation_throws() {
Element element = ElementFactory.createDiv();
assertThrows(IllegalArgumentException.class,
- () -> element.getJsInvoker(Serializable.class),
+ () -> element.executeJs(Serializable.class),
"an interface the build does not collect should be rejected");
}
@Test
- void getJsInvoker_notAnInterface_throws() {
+ void executeJsWithInvoker_notAnInterface_throws() {
Element element = ElementFactory.createDiv();
assertThrows(IllegalArgumentException.class,
- () -> element.getJsInvoker(ElementTest.class),
+ () -> element.executeJs(ElementTest.class),
"only an interface can declare invoker methods");
}
@Test
- void getJsInvoker_methodReturningAResult_schedulesAndReturnsIt() {
+ void executeJsWithInvoker_methodReturningAResult_schedulesAndReturnsIt() {
UI ui = new MockUI();
Element element = ElementFactory.createDiv();
ui.getElement().appendChild(element);
- ResultJs invoker = element.getJsInvoker(ResultJs.class);
+ ResultJs invoker = element.executeJs(ResultJs.class);
assertNotNull(invoker.toString(),
"the invoker should answer the methods of Object");
@@ -2725,12 +2725,12 @@ void getJsInvoker_methodReturningAResult_schedulesAndReturnsIt() {
}
@Test
- void getJsInvoker_methodWithAnotherReturnType_throws() {
+ void executeJsWithInvoker_methodWithAnotherReturnType_throws() {
Element element = ElementFactory.createDiv();
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
- () -> element.getJsInvoker(UnsupportedJs.class),
+ () -> element.executeJs(UnsupportedJs.class),
"a method the invoker can not answer should be refused when the invoker is handed out");
assertTrue(exception.getMessage().contains("readValue"),
@@ -2739,12 +2739,12 @@ void getJsInvoker_methodWithAnotherReturnType_throws() {
}
@Test
- void getJsInvoker_methodWithoutDeclaredJavaScript_throws() {
+ void executeJsWithInvoker_methodWithoutDeclaredJavaScript_throws() {
Element element = ElementFactory.createDiv();
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
- () -> element.getJsInvoker(UndeclaredJs.class));
+ () -> element.executeJs(UndeclaredJs.class));
assertTrue(exception.getMessage().contains("undeclared"),
"the message should name the method that declares nothing: "
@@ -2752,10 +2752,10 @@ void getJsInvoker_methodWithoutDeclaredJavaScript_throws() {
}
@Test
- void getJsInvoker_defaultAndStaticMethods_areNotDeclarations() {
+ void executeJsWithInvoker_defaultAndStaticMethods_areNotDeclarations() {
Element element = ElementFactory.createDiv();
- ComposingJs invoker = element.getJsInvoker(ComposingJs.class);
+ ComposingJs invoker = element.executeJs(ComposingJs.class);
// A static method belongs to the interface, not to the invoker, and a
// default method answers with whatever Java answers with
@@ -2765,12 +2765,12 @@ void getJsInvoker_defaultAndStaticMethods_areNotDeclarations() {
}
@Test
- void getJsInvoker_defaultMethodOnANonPublicInterface_throws() {
+ void executeJsWithInvoker_defaultMethodOnANonPublicInterface_throws() {
Element element = ElementFactory.createDiv();
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
- () -> element.getJsInvoker(NotPublicJs.class));
+ () -> element.executeJs(NotPublicJs.class));
assertTrue(exception.getMessage().contains("public"),
"the message should say what stops the method from running: "
@@ -2778,21 +2778,21 @@ void getJsInvoker_defaultMethodOnANonPublicInterface_throws() {
}
@Test
- void getJsInvoker_defaultMethodDeclaringJavaScript_throws() {
+ void executeJsWithInvoker_defaultMethodDeclaringJavaScript_throws() {
Element element = ElementFactory.createDiv();
assertThrows(IllegalArgumentException.class,
- () -> element.getJsInvoker(ContradictoryJs.class),
+ () -> element.executeJs(ContradictoryJs.class),
"a method can run in Java or in the browser, not both");
}
@Test
- void getJsInvoker_defaultMethod_runsInJavaAndSchedulesWhatItCalls() {
+ void executeJsWithInvoker_defaultMethod_runsInJavaAndSchedulesWhatItCalls() {
UI ui = new MockUI();
Element element = ElementFactory.createDiv();
ui.getElement().appendChild(element);
- element.getJsInvoker(ComposingJs.class).twice("foo");
+ element.executeJs(ComposingJs.class).twice("foo");
ui.getInternals().getStateTree().runExecutionsBeforeClientResponse();
List pendingJs = ui.getInternals()
From 81530a21bc8fc0665af3bfa2a8268d9514ef4952 Mon Sep 17 00:00:00 2001
From: "totally-not-ai[bot]"
<290682512+totally-not-ai[bot]@users.noreply.github.com>
Date: Sat, 19 Sep 2026 19:08:18 +0000
Subject: [PATCH 35/57] docs: say that the interface version answers rather
than runs
Its summary line claimed it runs the JavaScript, which the version that
takes an expression does but this one does not: it answers with an
implementation, and calling a method of that is what runs anything. The
summary is the line a reader sees in a method list, so it is the line
that has to be true.
---
.../main/java/com/vaadin/flow/dom/Element.java | 16 ++++++++--------
1 file changed, 8 insertions(+), 8 deletions(-)
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 2af8bc7e4f7..01f3485e434 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
@@ -1953,15 +1953,15 @@ public PendingJavaScriptResult executeJs(String expression,
}
/**
- * Asynchronously runs the JavaScript that the given interface declares, in
- * the browser in the context of this element.
+ * Answers with an implementation of the given interface, through which the
+ * JavaScript it declares is run asynchronously in the browser in the
+ * context of this element.
*
* The version that takes an interface rather than an expression: the
* interface is annotated with {@link JsInvoker} and each of its methods
- * declares the JavaScript it runs with {@link JsExpression}. This method
- * answers with the interface, and calling a method of it runs that
- * JavaScript with the method arguments as its parameters and this element
- * as this:
+ * declares the JavaScript it runs with {@link JsExpression}. Calling a
+ * method of the implementation runs that JavaScript with the method
+ * arguments as its parameters and this element as this:
*
*
* @JsInvoker
@@ -2002,8 +2002,8 @@ public PendingJavaScriptResult executeJs(String expression,
* the invoker interface type
* @param invokerType
* the invoker interface, not null
- * @return the interface, to call the declared JavaScript through, not
- * null
+ * @return an implementation of the interface, to call the declared
+ * JavaScript through, not null
* @throws IllegalArgumentException
* if the type is not an interface, is not annotated with
* {@link JsInvoker}, or has a method the invoker can not answer
From 7759d6963734f924dda8f4e1e3cdef9cc6cad604 Mon Sep 17 00:00:00 2001
From: "totally-not-ai[bot]"
<290682512+totally-not-ai[bot]@users.noreply.github.com>
Date: Sun, 20 Sep 2026 08:03:10 +0000
Subject: [PATCH 36/57] refactor: name it a JavaScript definition rather than
an invoker
The interface declares the JavaScript that its methods run; it does not
invoke anything by itself, so the old name pointed at the wrong half of
the mechanism. @JsInvoker is now @JsDefinition and JsInvokerCall is
JsCall, and the name follows through the whole chain: the generated file
is vaadin-js-definitions.js, the client registry is
window.Vaadin.Flow.jsDefinitions, and the target that ends an invocation
names the definition it calls.
Also from this round of review: the cases about the generated file now
live in the test of the task that writes it rather than in the test of
the hotswapper that asks for it, rendering the file content stays
package-private, and the bundle test no longer repeats a case that a
changed hash already covers.
---
.../server/frontend/BundleValidationUtil.java | 35 ++--
.../flow/server/frontend/NodeTasks.java | 4 +-
.../frontend/TaskGenerateBootstrap.java | 5 +-
...rs.java => TaskGenerateJsDefinitions.java} | 154 +++++++++---------
.../TaskGenerateWebComponentBootstrap.java | 4 +-
.../server/frontend/BundleValidationTest.java | 35 ++--
...ava => TaskGenerateJsDefinitionsTest.java} | 101 +++++++++---
.../client/flow/ExecuteJavaScriptProcessor.ts | 48 +++---
.../flow/ExecuteJavaScriptProcessorTests.ts | 46 +++---
.../com/vaadin/flow/component/Focusable.java | 8 +-
.../flow/component/internal/UIInternals.java | 31 ++--
.../java/com/vaadin/flow/dom/Element.java | 117 ++++++-------
.../vaadin/flow/internal/FrontendUtils.java | 6 +-
.../js/{JsInvokerCall.java => JsCall.java} | 52 +++---
.../js/{JsInvoker.java => JsDefinition.java} | 2 +-
.../java/com/vaadin/flow/js/JsExpression.java | 6 +-
.../flow/server/communication/UidlWriter.java | 26 +--
.../com/vaadin/flow/shared/JsonConstants.java | 20 +--
.../src/main/resources/vite.generated.ts | 14 +-
.../vaadin/flow/component/FocusableTest.java | 26 +--
.../java/com/vaadin/flow/dom/ElementTest.java | 68 ++++----
...JsInvokerCallTest.java => JsCallTest.java} | 14 +-
.../server/communication/UidlWriterTest.java | 20 +--
.../devserver/devloop/DevLoopRedefiner.java | 13 +-
...apper.java => JsDefinitionHotswapper.java} | 40 ++---
.../startup/DevModeStartupListener.java | 4 +-
...in.base.devserver.hotswap.VaadinHotswapper | 2 +-
.../devloop/DevLoopRedefinerTest.java | 10 +-
...t.java => JsDefinitionHotswapperTest.java} | 152 ++++++-----------
.../startup/DevModeClassFinderTest.java | 4 +-
30 files changed, 539 insertions(+), 528 deletions(-)
rename flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/{TaskGenerateJsInvokers.java => TaskGenerateJsDefinitions.java} (64%)
rename flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/{TaskGenerateJsInvokersTest.java => TaskGenerateJsDefinitionsTest.java} (57%)
rename flow-server/src/main/java/com/vaadin/flow/js/{JsInvokerCall.java => JsCall.java} (78%)
rename flow-server/src/main/java/com/vaadin/flow/js/{JsInvoker.java => JsDefinition.java} (98%)
rename flow-server/src/test/java/com/vaadin/flow/js/{JsInvokerCallTest.java => JsCallTest.java} (92%)
rename vaadin-dev-server/src/main/java/com/vaadin/base/devserver/hotswap/impl/{JsInvokerHotswapper.java => JsDefinitionHotswapper.java} (77%)
rename vaadin-dev-server/src/test/java/com/vaadin/base/devserver/hotswap/impl/{JsInvokerHotswapperTest.java => JsDefinitionHotswapperTest.java} (51%)
diff --git a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/BundleValidationUtil.java b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/BundleValidationUtil.java
index efb672f8866..5627c1c6d95 100644
--- a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/BundleValidationUtil.java
+++ b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/BundleValidationUtil.java
@@ -278,15 +278,16 @@ private static boolean needsBuildInternal(Options options,
((ObjectNode) statsJson.get(FRONTEND_HASHES_STATS_KEY)).remove(
FrontendUtils.GENERATED + FrontendUtils.COMMERCIAL_BANNER_JS);
- if (jsInvokersChanged(options, statsJson)) {
+ if (jsDefinitionsChanged(options, statsJson)) {
UsageStatistics.markAsUsed(
- "flow/rebundle-reason-changed-js-invokers", null);
+ "flow/rebundle-reason-changed-js-definitions", null);
return true;
}
- // js invoker file hash has already been checked
+ // JavaScript definition file hash has already been checked
// removing it from hashes map to prevent other unnecessary checks
- ((ObjectNode) statsJson.get(FRONTEND_HASHES_STATS_KEY)).remove(
- FrontendUtils.GENERATED + FrontendUtils.JS_INVOKERS_FILE_NAME);
+ ((ObjectNode) statsJson.get(FRONTEND_HASHES_STATS_KEY))
+ .remove(FrontendUtils.GENERATED
+ + FrontendUtils.JS_DEFINITIONS_FILE_NAME);
if (!BundleValidationUtil.frontendImportsFound(statsJson, options)) {
UsageStatistics.markAsUsed(
@@ -1004,29 +1005,31 @@ private static boolean isCommercialBannerConditionChanged(Options options,
}
/**
- * Checks whether the JavaScript that the {@code @JsInvoker} interfaces of
- * the application declare differs from what the bundle was built with.
+ * Checks whether the JavaScript that the {@code @JsDefinition} interfaces
+ * of the application declare differs from what the bundle was built with.
*
* The functions are generated into the bundle, so a declaration that
- * changed, an invoker that was added and a bundle built before invokers
- * existed all mean that the bundle no longer contains what a call would
- * look up, which shows up at runtime as a call that cannot be run.
+ * changed, a definition that was added and a bundle built before any
+ * definition existed all mean that the bundle no longer contains what a
+ * call would look up, which shows up at runtime as a call that cannot be
+ * run.
*/
- private static boolean jsInvokersChanged(Options options,
+ private static boolean jsDefinitionsChanged(Options options,
JsonNode statsJson) {
JsonNode frontendHashes = statsJson.get(FRONTEND_HASHES_STATS_KEY);
- String jsInvokersPath = FrontendUtils.GENERATED
- + FrontendUtils.JS_INVOKERS_FILE_NAME;
- String content = new TaskGenerateJsInvokers(options).getFileContent();
+ String jsDefinitionsPath = FrontendUtils.GENERATED
+ + FrontendUtils.JS_DEFINITIONS_FILE_NAME;
+ String content = new TaskGenerateJsDefinitions(options)
+ .getFileContent();
List faultyContent = new ArrayList<>();
- compareFrontendHashes(frontendHashes, faultyContent, jsInvokersPath,
+ compareFrontendHashes(frontendHashes, faultyContent, jsDefinitionsPath,
content);
if (!faultyContent.isEmpty()) {
// Either the declarations changed, or the bundle was built before
// they existed and carries none of their JavaScript
getLogger().info(
- "Detected JavaScript declared by the invoker interfaces that the bundle does not carry");
+ "Detected JavaScript declared by the JavaScript definitions that the bundle does not carry");
return true;
}
return false;
diff --git a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/NodeTasks.java b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/NodeTasks.java
index f881f932068..ed8d2df20ff 100644
--- a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/NodeTasks.java
+++ b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/NodeTasks.java
@@ -76,7 +76,7 @@ public class NodeTasks implements FallibleCommand {
TaskGenerateWebComponentHtml.class,
TaskGenerateWebComponentBootstrap.class,
TaskGenerateFeatureFlags.class,
- TaskGenerateJsInvokers.class,
+ TaskGenerateJsDefinitions.class,
TaskInstallFrontendBuildPlugins.class,
TaskUpdatePackages.class,
TaskRunNpmInstall.class,
@@ -263,7 +263,7 @@ public NodeTasks(Options options) {
commands.add(new TaskGenerateFeatureFlags(options));
- commands.add(new TaskGenerateJsInvokers(options));
+ commands.add(new TaskGenerateJsDefinitions(options));
if (options.getJarFiles() != null
&& options.getJarFrontendResourcesFolder() != null) {
diff --git a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateBootstrap.java b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateBootstrap.java
index 21e8158fcb5..51cb15975ed 100644
--- a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateBootstrap.java
+++ b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateBootstrap.java
@@ -32,7 +32,7 @@
import static com.vaadin.flow.internal.FrontendUtils.INDEX_JS;
import static com.vaadin.flow.internal.FrontendUtils.INDEX_TS;
import static com.vaadin.flow.internal.FrontendUtils.INDEX_TSX;
-import static com.vaadin.flow.internal.FrontendUtils.JS_INVOKERS_FILE_NAME;
+import static com.vaadin.flow.internal.FrontendUtils.JS_DEFINITIONS_FILE_NAME;
/**
* A task for generating the bootstrap file
@@ -84,7 +84,8 @@ protected String getFileContent() {
for (TypeScriptBootstrapModifier modifier : modifiers) {
modifier.modify(lines, options);
}
- lines.add(0, String.format("import './%s';%n", JS_INVOKERS_FILE_NAME));
+ lines.add(0,
+ String.format("import './%s';%n", JS_DEFINITIONS_FILE_NAME));
lines.add(0,
String.format("import './%s';%n", FEATURE_FLAGS_FILE_NAME));
return String.join(System.lineSeparator(), lines);
diff --git a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokers.java b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitions.java
similarity index 64%
rename from flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokers.java
rename to flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitions.java
index 1da4c31cacf..423bee8f348 100644
--- a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokers.java
+++ b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitions.java
@@ -34,16 +34,16 @@
import org.slf4j.LoggerFactory;
import com.vaadin.flow.internal.FrontendUtils;
+import com.vaadin.flow.js.JsCall;
+import com.vaadin.flow.js.JsDefinition;
import com.vaadin.flow.js.JsExpression;
-import com.vaadin.flow.js.JsInvoker;
-import com.vaadin.flow.js.JsInvokerCall;
import static com.vaadin.flow.internal.FrontendUtils.GENERATED;
-import static com.vaadin.flow.internal.FrontendUtils.JS_INVOKERS_FILE_NAME;
+import static com.vaadin.flow.internal.FrontendUtils.JS_DEFINITIONS_FILE_NAME;
/**
- * Generates {@link FrontendUtils#JS_INVOKERS_FILE_NAME}, which registers the
- * JavaScript of every {@link JsInvoker} interface on the class path as an
+ * Generates {@link FrontendUtils#JS_DEFINITIONS_FILE_NAME}, which registers the
+ * JavaScript of every {@link JsDefinition} interface on the class path as an
* ordinary function of the bundle.
*
* This is what lets the client run a server-initiated call without compiling
@@ -54,45 +54,46 @@
*
* For internal use only. May be renamed or removed in a future release.
*/
-public class TaskGenerateJsInvokers extends AbstractTaskClientGenerator {
+public class TaskGenerateJsDefinitions extends AbstractTaskClientGenerator {
- private static final Pattern INVOKER_KEY = Pattern
- .compile("window\\.Vaadin\\.Flow\\.jsInvokers\\[\"([^\"]+)\"\\] =");
+ private static final Pattern DEFINITION_KEY = Pattern.compile(
+ "window\\.Vaadin\\.Flow\\.jsDefinitions\\[\"([^\"]+)\"\\] =");
private final Options options;
- TaskGenerateJsInvokers(Options options) {
+ TaskGenerateJsDefinitions(Options options) {
this.options = options;
}
@Override
protected String getFileContent() {
- return renderFileContent(
- options.getClassFinder().getAnnotatedClasses(JsInvoker.class));
+ return renderFileContent(options.getClassFinder()
+ .getAnnotatedClasses(JsDefinition.class));
}
/**
- * Renders the file that registers the JavaScript of the given invoker
+ * Renders the file that registers the JavaScript of the given definition
* interfaces.
*
- * Exposed so that a caller which regenerates the file outside a build - the
- * hotswap path, which writes it again when an interface changed while the
- * application runs - produces exactly what a build would have written.
+ * Package private: what regenerating the file outside a build looks like is
+ * {@link #updateJsDefinitions(Options, Collection)}, which goes through
+ * this.
*
- * @param invokers
- * the invoker interfaces to render, not null
+ * @param definitions
+ * the JavaScript definitions to render, not null
* @return the content of the generated file
*/
- public static String renderFileContent(Collection> invokers) {
+ static String renderFileContent(Collection> definitions) {
List lines = new ArrayList<>();
lines.add("// @ts-nocheck");
lines.add("window.Vaadin = window.Vaadin || {};");
lines.add("window.Vaadin.Flow = window.Vaadin.Flow || {};");
lines.add(
- "window.Vaadin.Flow.jsInvokers = window.Vaadin.Flow.jsInvokers || {};");
+ "window.Vaadin.Flow.jsDefinitions = window.Vaadin.Flow.jsDefinitions || {};");
- invokers.stream().sorted(Comparator.comparing(Class::getName))
- .forEach(invoker -> lines.addAll(renderInvokerLines(invoker)));
+ definitions.stream().sorted(Comparator.comparing(Class::getName))
+ .forEach(definition -> lines
+ .addAll(renderDefinitionLines(definition)));
// Writing this file again while the application runs replaces it in the
// browser that has it: everything above only writes into the registry,
@@ -111,26 +112,27 @@ public static String renderFileContent(Collection> invokers) {
}
/**
- * The invoker interfaces of the given ones whose JavaScript the generated
- * file does not carry, which is the JavaScript a browser can run of them.
+ * The JavaScript definitions of the given ones whose JavaScript the
+ * generated file does not carry, which is the JavaScript a browser can run
+ * of them.
*
* @param options
* where the file is, not null
- * @param invokers
- * the invoker interfaces to look for, not null
+ * @param definitions
+ * the JavaScript definitions to look for, not null
* @return those the file does not carry, empty when it carries all of them
*/
public static List> missingFromGeneratedFile(Options options,
- Collection> invokers) {
+ Collection> definitions) {
String generated = readGeneratedFile(options);
- return invokers.stream()
- .filter(invoker -> !isInGeneratedFile(invoker, generated))
+ return definitions.stream()
+ .filter(definition -> !isInGeneratedFile(definition, generated))
.toList();
}
/**
- * Writes the generated file again so that it carries what the given invoker
- * interfaces declare, for a caller that has to update it while the
+ * Writes the generated file again so that it carries what the given
+ * definitions declare, for a caller that has to update it while the
* application runs rather than as part of a build.
*
* The interfaces the file already registers are kept: they are what a
@@ -146,26 +148,27 @@ public static List