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> missingFromGeneratedFile(Options options, * * @param options * where the file is, not null - * @param invokers - * the invoker interfaces to write it for, not null - * and not empty + * @param definitions + * the JavaScript definitions 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 List> updateJsInvokers(Options options, - Collection> invokers) { + public static List> updateJsDefinitions(Options options, + Collection> definitions) { String generated = readGeneratedFile(options); - String content = renderFileContent(withInvokersOf(generated, invokers)); + String content = renderFileContent( + withDefinitionsOf(generated, definitions)); - TaskGenerateJsInvokers task = new TaskGenerateJsInvokers(options); + TaskGenerateJsDefinitions task = new TaskGenerateJsDefinitions(options); try { task.writeIfChanged(task.getGeneratedFile(), content); } catch (IOException e) { getLogger().debug("Could not write {}", task.getGeneratedFile(), e); // The file is as it was, so only what it was already missing is // missing now - return invokers.stream() - .filter(invoker -> !isInGeneratedFile(invoker, generated)) + return definitions.stream().filter( + definition -> !isInGeneratedFile(definition, generated)) .toList(); } // Everything asked for went into the content that was written @@ -173,18 +176,18 @@ public static List> updateJsInvokers(Options options, } /** - * 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. + * Whether the given content carries what the definition 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, + private static boolean isInGeneratedFile(Class definition, String generated) { if (generated == null) { return false; } - List declared = renderInvokerLines(invoker); + List declared = renderDefinitionLines(definition); if (declared.isEmpty()) { // Declares no JavaScript, so there is nothing to carry return true; @@ -197,26 +200,28 @@ private static boolean isInGeneratedFile(Class invoker, * 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) { + private static Collection> withDefinitionsOf(String generated, + Collection> definitions) { Map> byName = new LinkedHashMap<>(); - invokers.forEach(invoker -> byName.put(invoker.getName(), invoker)); - ClassLoader classLoader = invokers.iterator().next().getClassLoader(); - for (String name : readInvokerNames(generated)) { + definitions.forEach( + definition -> byName.put(definition.getName(), definition)); + ClassLoader classLoader = definitions.iterator().next() + .getClassLoader(); + for (String name : readDefinitionNames(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); + getLogger().debug("Could not load the definition {}", name, e); } } return byName.values(); } private static String readGeneratedFile(Options options) { - File generatedFile = new TaskGenerateJsInvokers(options) + File generatedFile = new TaskGenerateJsDefinitions(options) .getGeneratedFile(); if (!generatedFile.exists()) { return null; @@ -231,14 +236,14 @@ private static String readGeneratedFile(Options options) { } private static Logger getLogger() { - return LoggerFactory.getLogger(TaskGenerateJsInvokers.class); + return LoggerFactory.getLogger(TaskGenerateJsDefinitions.class); } /** - * Reads back the names of the invoker interfaces a generated file + * Reads back the names of the JavaScript definitions a generated file * registers, which is what a browser that has the file can run. *

- * Exposed together with {@link #renderInvokerLines(Class)} so that the + * Exposed together with {@link #renderDefinitionLines(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. @@ -248,12 +253,12 @@ private static Logger getLogger() { * @return the interface names the file registers, in the order it registers * them */ - public static List readInvokerNames(String fileContent) { + public static List readDefinitionNames(String fileContent) { List names = new ArrayList<>(); if (fileContent == null) { return names; } - Matcher matcher = INVOKER_KEY.matcher(fileContent); + Matcher matcher = DEFINITION_KEY.matcher(fileContent); while (matcher.find()) { String name = matcher.group(1); if (!names.contains(name)) { @@ -264,23 +269,23 @@ public static List readInvokerNames(String fileContent) { } /** - * Renders what one invoker interface contributes to the generated file: the - * registration of its interface name, and one function per method that + * Renders what one JavaScript definition 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. + * Package private: whether a file carries what an interface declares is + * answered by {@link #missingFromGeneratedFile(Options, Collection)}, which + * compares against this. * - * @param invoker - * the invoker interface to render, not null - * @return the lines this invoker contributes, empty if it declares no + * @param definition + * the JavaScript definition to render, not null + * @return the lines this definition contributes, empty if it declares no * JavaScript */ - public static List renderInvokerLines(Class invoker) { + static List renderDefinitionLines(Class definition) { List lines = new ArrayList<>(); List methods = new ArrayList<>(); - for (Method method : invoker.getMethods()) { + for (Method method : definition.getMethods()) { // A default method runs in Java, so it has nothing in the bundle // even if it carries the annotation if (method.isAnnotationPresent(JsExpression.class) @@ -291,15 +296,15 @@ public static List renderInvokerLines(Class invoker) { if (methods.isEmpty()) { return lines; } - methods.sort(Comparator.comparing(TaskGenerateJsInvokers::methodId)); + methods.sort(Comparator.comparing(TaskGenerateJsDefinitions::methodId)); lines.add(String.format( - "window.Vaadin.Flow.jsInvokers[%s] = Object.assign(window.Vaadin.Flow.jsInvokers[%s] || {}, {", - quote(invoker.getName()), quote(invoker.getName()))); + "window.Vaadin.Flow.jsDefinitions[%s] = Object.assign(window.Vaadin.Flow.jsDefinitions[%s] || {}, {", + quote(definition.getName()), quote(definition.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 + // the element the definition was obtained from is its `this` - the // same contract as an executeJs expression has. String parameters = IntStream.range(0, method.getParameterCount()) .mapToObj(index -> "$" + index) @@ -315,8 +320,7 @@ public static List renderInvokerLines(Class invoker) { } private static String methodId(Method method) { - return JsInvokerCall.methodId(method.getName(), - method.getParameterCount()); + return JsCall.methodId(method.getName(), method.getParameterCount()); } private static String quote(String value) { @@ -327,7 +331,7 @@ private static String quote(String value) { protected File getGeneratedFile() { File frontendGeneratedDirectory = new File( options.getFrontendDirectory(), GENERATED); - return new File(frontendGeneratedDirectory, JS_INVOKERS_FILE_NAME); + return new File(frontendGeneratedDirectory, JS_DEFINITIONS_FILE_NAME); } @Override 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 e651cc0ce6f..0ca8243590a 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,7 +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.JS_DEFINITIONS_FILE_NAME; import static com.vaadin.flow.internal.FrontendUtils.WEB_COMPONENT_BOOTSTRAP_FILE_NAME; /** @@ -60,7 +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(String.format("import './%s';%n", JS_DEFINITIONS_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 4cc6f7f75da..76e98c0cd61 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,11 +203,14 @@ 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 + // A bundle carries the JavaScript declared by the JavaScript + // definitions frontendHashes.put( - FrontendUtils.GENERATED + FrontendUtils.JS_INVOKERS_FILE_NAME, - BundleValidationUtil.calculateHash( - new TaskGenerateJsInvokers(options).getFileContent())); + FrontendUtils.GENERATED + + FrontendUtils.JS_DEFINITIONS_FILE_NAME, + BundleValidationUtil + .calculateHash(new TaskGenerateJsDefinitions(options) + .getFileContent())); return stats; } @@ -1074,31 +1077,15 @@ void frontendFileHashMatches_noBundleRebuild(Mode mode) throws IOException { @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 that carries none of the JavaScript the invoker interfaces declare would run an application that cannot make those calls"); - } - - @ParameterizedTest - @MethodSource("modes") - void jsInvokerJavaScriptChanged_bundleRebuild(Mode mode) { + void jsDefinitionJavaScriptChanged_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, + FrontendUtils.GENERATED + + FrontendUtils.JS_DEFINITIONS_FILE_NAME, "not the hash of what the interfaces declare"); setupFrontendUtilsMock(stats); @@ -1106,7 +1093,7 @@ void jsInvokerJavaScriptChanged_bundleRebuild(Mode mode) { depScanner, mode); assertTrue(needsBuild, - "JavaScript declared by an invoker interface that the bundle was not built with should trigger a rebuild"); + "JavaScript declared by a JavaScript definition that the bundle was not built with should trigger a rebuild, whether the bundle carries another version of it or none at all"); } @ParameterizedTest 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/TaskGenerateJsDefinitionsTest.java similarity index 57% rename from flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsInvokersTest.java rename to flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitionsTest.java index d626f5ce6ee..bc4ff80a57a 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/TaskGenerateJsDefinitionsTest.java @@ -29,21 +29,21 @@ import com.vaadin.flow.di.Lookup; import com.vaadin.flow.internal.FrontendUtils; +import com.vaadin.flow.js.JsDefinition; 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; 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; 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 { +class TaskGenerateJsDefinitionsTest { - @JsInvoker + @JsDefinition public interface GreeterJs extends Serializable { @JsExpression("window.alert({ text: $0, kind: 'greeting' })") void showGreeting(String greeting); @@ -52,13 +52,13 @@ public interface GreeterJs extends Serializable { void showGreeting(); } - @JsInvoker + @JsDefinition public interface CounterJs extends Serializable { @JsExpression("this.count = ($0 || 0) + 1") void count(Integer from); } - @JsInvoker + @JsDefinition public interface NothingJs extends Serializable { void notDeclared(); } @@ -66,7 +66,7 @@ public interface NothingJs extends Serializable { @TempDir File temporaryFolder; - private TaskGenerateJsInvokers task; + private TaskGenerateJsDefinitions task; private Options options; private File frontendFolder; @@ -78,7 +78,7 @@ void setUp() { new DefaultClassFinder( Set.of(GreeterJs.class, NothingJs.class)), null).withFrontendDirectory(frontendFolder); - task = new TaskGenerateJsInvokers(options); + task = new TaskGenerateJsDefinitions(options); } @Test @@ -88,9 +88,9 @@ void generatesAFunctionPerDeclaredExpression() String content = task.getFileContent(); assertTrue( - content.contains("window.Vaadin.Flow.jsInvokers[\"" + content.contains("window.Vaadin.Flow.jsDefinitions[\"" + GreeterJs.class.getName() + "\"]"), - "the invoker should be registered under its class name: " + "the definition should be registered under its class name: " + content); assertTrue( content.contains("\"showGreeting/1\": async function ($0) {"), @@ -106,7 +106,7 @@ void generatesAFunctionPerDeclaredExpression() } @Test - void invokerWithoutDeclaredJavaScript_isNotRegistered() + void definitionWithoutDeclaredJavaScript_isNotRegistered() throws ExecutionFailedException { task.execute(); String content = task.getFileContent(); @@ -117,20 +117,20 @@ void invokerWithoutDeclaredJavaScript_isNotRegistered() } @Test - void updateJsInvokers_dropsAnInvokerTheFileNamesAndNothingHas() + void updateJsDefinitions_dropsADefinitionTheFileNamesAndNothingHas() 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); + FrontendUtils.JS_DEFINITIONS_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)); + List> missing = TaskGenerateJsDefinitions + .updateJsDefinitions(options, List.of(GreeterJs.class)); assertTrue(missing.isEmpty(), "the interface that was asked for should be in the file"); @@ -141,7 +141,7 @@ void updateJsInvokers_dropsAnInvokerTheFileNamesAndNothingHas() } @Test - void updateJsInvokers_fileNotWritable_answersWithWhatItDoesNotCarry() + void updateJsDefinitions_fileNotWritable_answersWithWhatItDoesNotCarry() throws ExecutionFailedException { // A file that carries one of the two interfaces, and a folder nothing // can be written into @@ -152,8 +152,9 @@ void updateJsInvokers_fileNotWritable_answersWithWhatItDoesNotCarry() "the folder has to be made read only for this"); try { - List> missing = TaskGenerateJsInvokers.updateJsInvokers( - options, List.of(GreeterJs.class, CounterJs.class)); + List> missing = TaskGenerateJsDefinitions + .updateJsDefinitions(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"); @@ -162,13 +163,75 @@ void updateJsInvokers_fileNotWritable_answersWithWhatItDoesNotCarry() } } + @Test + void missingFromGeneratedFile_answersForWhatTheFileCarries() + throws ExecutionFailedException, IOException { + task.execute(); + File generated = new File( + FrontendUtils.getFrontendGeneratedFolder(frontendFolder), + FrontendUtils.JS_DEFINITIONS_FILE_NAME); + String carried = Files.readString(generated.toPath()); + + assertTrue( + TaskGenerateJsDefinitions.missingFromGeneratedFile(options, + List.of(GreeterJs.class)).isEmpty(), + "the file was written from this interface"); + + // The JavaScript it declared before it was shortened: the browser + // would keep running the extra statement + Files.writeString(generated.toPath(), + carried.replace("window.alert({ text: $0, kind: 'greeting' })", + "window.alert($0)")); + assertEquals(List.of(GreeterJs.class), + TaskGenerateJsDefinitions.missingFromGeneratedFile(options, + List.of(GreeterJs.class)), + "another version of the declarations is not the declarations"); + + // What renaming or moving the interface leaves behind: the methods and + // the JavaScript are there, under the name of before + Files.writeString(generated.toPath(), carried + .replace(GreeterJs.class.getName(), "com.example.RenamedJs")); + assertEquals(List.of(GreeterJs.class), + TaskGenerateJsDefinitions.missingFromGeneratedFile(options, + List.of(GreeterJs.class)), + "a call looks the interface up by name, so the name is part of carrying it"); + + Files.delete(generated.toPath()); + assertEquals(List.of(GreeterJs.class), TaskGenerateJsDefinitions + .missingFromGeneratedFile(options, List.of(GreeterJs.class)), + "no file carries nothing"); + } + + @Test + void updateJsDefinitions_writesWhatIsAskedForBesideWhatTheFileHolds() + throws ExecutionFailedException, IOException { + // A file written before the other interface was annotated: nothing has + // scanned for it, and what the file holds has to stay in it + task.execute(); + File generated = new File( + FrontendUtils.getFrontendGeneratedFolder(frontendFolder), + FrontendUtils.JS_DEFINITIONS_FILE_NAME); + + List> missing = TaskGenerateJsDefinitions + .updateJsDefinitions(options, List.of(CounterJs.class)); + + assertTrue(missing.isEmpty()); + String written = Files.readString(generated.toPath()); + assertTrue(written.contains(CounterJs.class.getName()), + "the interface that was asked for should be in the file: " + + written); + assertTrue(written.contains(GreeterJs.class.getName()), + "the interface the file held should still be in it: " + + written); + } + @Test void writesTheFileTheBootstrapImports() throws ExecutionFailedException { task.execute(); assertTrue( new File(new File(frontendFolder, GENERATED), - JS_INVOKERS_FILE_NAME).exists(), + JS_DEFINITIONS_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 8b69a1a90b5..64a6aede3c8 100644 --- a/flow-client/src/main/frontend/internal/client/flow/ExecuteJavaScriptProcessor.ts +++ b/flow-client/src/main/frontend/internal/client/flow/ExecuteJavaScriptProcessor.ts @@ -67,34 +67,34 @@ interface ContextCallbacks { } /** - * 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 + * What an invocation of declared JavaScript ends with instead of an expression: + * the definition 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; +export interface JsDefinitionTarget { + definition: string; method: string; arguments: number; returns?: boolean; } -type JsInvokerFunction = (this: unknown, ...args: unknown[]) => unknown; +type JsDefinitionFunction = (this: unknown, ...args: unknown[]) => unknown; type ReturnChannel = (value: unknown) => void; /** - * Looks up the function that the build generated for an invoker method. The + * Looks up the function that the build generated for a definition 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 { +function findDeclaredFunction(definition: string, method: string): JsDefinitionFunction | undefined { const registry = ( window as unknown as { - Vaadin?: { Flow?: { jsInvokers?: Record> } }; + Vaadin?: { Flow?: { jsDefinitions?: Record> } }; } - ).Vaadin?.Flow?.jsInvokers; - return registry?.[invoker]?.[method]; + ).Vaadin?.Flow?.jsDefinitions; + return registry?.[definition]?.[method]; } /** @@ -157,12 +157,12 @@ 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. 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); + // A call of declared JavaScript: the bundle has the function, the + // server sent only 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 JsDefinitionTarget, parameters); return; } @@ -239,7 +239,7 @@ export class ExecuteJavaScriptProcessor { } /** - * Executes a call made through a JS invoker: looks the function up in the + * Executes a call made through a JavaScript definition: 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 @@ -247,12 +247,12 @@ export class ExecuteJavaScriptProcessor { * * Protected instead of private for testing purposes, as `invoke` is. * - * @param target - the invoker interface and method to run + * @param target - the JavaScript definition 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 { + protected invokeFromBundle(target: JsDefinitionTarget, parameters: unknown[]): void { const argumentCount = target.arguments; // The parameters are the arguments of the call, then the element to apply @@ -263,7 +263,7 @@ 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) { - 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.`; + const message = `Expected ${expectedCount} parameters for ${target.definition}.${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 @@ -281,15 +281,15 @@ export class ExecuteJavaScriptProcessor { 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); + const fn = findDeclaredFunction(target.definition, 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.`; + const message = `No JavaScript in the bundle for ${target.definition}.${target.method}. The JavaScript definition is annotated with @JsDefinition, 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 + // The element the definition was obtained from is the parameter after the // arguments, and it is what the function runs against. const thisArg = parameters[argumentCount]; try { @@ -300,7 +300,7 @@ export class ExecuteJavaScriptProcessor { } catch (exception) { Console.reportStacktrace(exception); Console.error( - `Exception is thrown while running ${target.invoker}.${target.method}. Stacktrace will be dumped separately.` + `Exception is thrown while running ${target.definition}.${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 9b52e16f903..c9ea7408019 100644 --- a/flow-client/src/test/frontend/internal/client/flow/ExecuteJavaScriptProcessorTests.ts +++ b/flow-client/src/test/frontend/internal/client/flow/ExecuteJavaScriptProcessorTests.ts @@ -69,22 +69,22 @@ function registeredNode(registry: TestRegistry, id: number): StateNode { } describe('ExecuteJavaScriptProcessor', () => { - describe('js invoker calls', () => { - const INVOKER = 'com.acme.GreeterJs'; + describe('JavaScript definition calls', () => { + const DEFINITION = 'com.acme.GreeterJs'; - type InvokerFunction = (this: unknown, ...args: unknown[]) => unknown; + type DefinitionFunction = (this: unknown, ...args: unknown[]) => unknown; - type InvokerWindow = Window & { - Vaadin?: { Flow?: { jsInvokers?: Record> } }; + type DefinitionWindow = Window & { + Vaadin?: { Flow?: { jsDefinitions?: 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; + function registerDefinition(method: string, fn: DefinitionFunction): void { + const vaadin = (window as DefinitionWindow).Vaadin ?? {}; + (window as DefinitionWindow).Vaadin = vaadin; vaadin.Flow = vaadin.Flow ?? {}; - vaadin.Flow.jsInvokers = vaadin.Flow.jsInvokers ?? {}; - vaadin.Flow.jsInvokers[INVOKER] = { ...vaadin.Flow.jsInvokers[INVOKER], [method]: fn }; + vaadin.Flow.jsDefinitions = vaadin.Flow.jsDefinitions ?? {}; + vaadin.Flow.jsDefinitions[DEFINITION] = { ...vaadin.Flow.jsDefinitions[DEFINITION], [method]: fn }; } function processor(): ExecuteJavaScriptProcessor { @@ -97,17 +97,17 @@ describe('ExecuteJavaScriptProcessor', () => { } afterEach(() => { - delete (window as InvokerWindow).Vaadin?.Flow?.jsInvokers?.[INVOKER]; + delete (window as DefinitionWindow).Vaadin?.Flow?.jsDefinitions?.[DEFINITION]; }); 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[]) { + registerDefinition('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 }]]); + processor().execute([['Hello', element, { definition: DEFINITION, method: 'showGreeting/1', arguments: 1 }]]); expect(calls).to.have.lengthOf(1); expect(calls[0].thisArg).to.equal(element); @@ -115,7 +115,7 @@ describe('ExecuteJavaScriptProcessor', () => { }); it('passes the return value to the success channel', async () => { - registerInvoker('readValue/0', () => 'answer'); + registerDefinition('readValue/0', () => 'answer'); const resolved: unknown[] = []; const element = { tagName: 'div' }; @@ -124,7 +124,7 @@ describe('ExecuteJavaScriptProcessor', () => { element, (value: unknown) => resolved.push(value), () => {}, - { invoker: INVOKER, method: 'readValue/0', arguments: 0, returns: true } + { definition: DEFINITION, method: 'readValue/0', arguments: 0, returns: true } ] ]); // Settled in microtasks: a macrotask wait would also pick up the @@ -137,20 +137,20 @@ describe('ExecuteJavaScriptProcessor', () => { it('does not run a call whose parameters do not match the target', () => { let calls = 0; - registerInvoker('showGreeting/1', () => { + registerDefinition('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 }]]); + processor().execute([['Hello', { definition: DEFINITION, method: 'showGreeting/1', arguments: 1 }]]); 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', () => { + registerDefinition('readValue/0', () => { calls += 1; return 'answer'; }); @@ -162,7 +162,7 @@ describe('ExecuteJavaScriptProcessor', () => { [ element, (error: unknown) => errors.push(error), - { invoker: INVOKER, method: 'readValue/0', arguments: 0, returns: true } + { definition: DEFINITION, method: 'readValue/0', arguments: 0, returns: true } ] ]); @@ -174,12 +174,12 @@ describe('ExecuteJavaScriptProcessor', () => { it('does not run a call that carries more parameters than the target declares', () => { let calls = 0; - registerInvoker('showGreeting/1', () => { + registerDefinition('showGreeting/1', () => { calls += 1; }); processor().execute([ - ['Hello', 'unexpected', { tagName: 'div' }, { invoker: INVOKER, method: 'showGreeting/1', arguments: 1 }] + ['Hello', 'unexpected', { tagName: 'div' }, { definition: DEFINITION, method: 'showGreeting/1', arguments: 1 }] ]); expect(calls).to.equal(0); @@ -194,12 +194,12 @@ describe('ExecuteJavaScriptProcessor', () => { element, () => {}, (error: unknown) => errors.push(error), - { invoker: INVOKER, method: 'missing/0', arguments: 0, returns: true } + { definition: DEFINITION, method: 'missing/0', arguments: 0, returns: true } ] ]); expect(errors).to.have.lengthOf(1); - expect(String(errors[0])).to.contain(INVOKER); + expect(String(errors[0])).to.contain(DEFINITION); }); }); 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 37cd52f0086..29899f100a2 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 @@ -21,8 +21,8 @@ import tools.jackson.databind.node.ObjectNode; import com.vaadin.flow.dom.Element; +import com.vaadin.flow.js.JsDefinition; import com.vaadin.flow.js.JsExpression; -import com.vaadin.flow.js.JsInvoker; /** * Represents a component that can gain and lose focus. @@ -213,15 +213,15 @@ default ShortcutRegistration addFocusShortcut(Key key, } /** - * The client-side operations behind {@link Focusable}, as an invoker - * interface for {@link Element#executeJs(Class)}. + * The client-side operations behind {@link Focusable}, as a JavaScript + * definition 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 * client side that implements this interface instead of running the scripts * is responsible for the same. */ - @JsInvoker + @JsDefinition 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 cd32f9f7342..666ae0910b5 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 @@ -77,7 +77,7 @@ import com.vaadin.flow.internal.nodefeature.PushConfigurationMap; import com.vaadin.flow.internal.nodefeature.ReconnectDialogConfigurationMap; import com.vaadin.flow.internal.streams.ActiveTransfer; -import com.vaadin.flow.js.JsInvokerCall; +import com.vaadin.flow.js.JsCall; import com.vaadin.flow.router.AfterNavigationListener; import com.vaadin.flow.router.BeforeEnterListener; import com.vaadin.flow.router.BeforeLeaveEvent.ContinueNavigationAction; @@ -129,7 +129,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 JsInvokerCall invokerCall; + private final @Nullable JsCall jsCall; /** * Creates a new invocation. @@ -141,14 +141,14 @@ public static class JavaScriptInvocation implements Serializable { * @since 25.0 */ public JavaScriptInvocation(String expression, Object... parameters) { - this((JsInvokerCall) null, expression, parameters); + this((JsCall) null, expression, parameters); } /** - * Creates a new invocation for the given invoker call, whose expression - * and parameters the caller has already resolved. + * Creates a new invocation for the given call, whose expression and + * parameters the caller has already resolved. * - * @param invokerCall + * @param jsCall * the call that this invocation performs, or * null if the invocation is plain JavaScript * @param expression @@ -156,8 +156,8 @@ public JavaScriptInvocation(String expression, Object... parameters) { * @param parameters * a list of parameters to use when invoking the script */ - public JavaScriptInvocation(@Nullable JsInvokerCall invokerCall, - String expression, Object... parameters) { + public JavaScriptInvocation(@Nullable JsCall jsCall, 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. @@ -171,7 +171,7 @@ public JavaScriptInvocation(@Nullable JsInvokerCall invokerCall, this.expression = expression; Collections.addAll(this.parameters, parameters); - this.invokerCall = invokerCall; + this.jsCall = jsCall; } /** @@ -193,17 +193,16 @@ public List getParameters() { } /** - * 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. + * Gets the 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 call, or null if the invocation is plain * JavaScript scheduled with an expression */ - public @Nullable JsInvokerCall getInvokerCall() { - return invokerCall; + public @Nullable JsCall getJsCall() { + return jsCall; } } 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 01f3485e434..a0fe9901018 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 @@ -69,9 +69,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.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 com.vaadin.flow.server.AbstractStreamResource; import com.vaadin.flow.server.Command; import com.vaadin.flow.server.StreamResource; @@ -1958,13 +1958,13 @@ public PendingJavaScriptResult executeJs(String expression, * 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 + * interface is annotated with {@link JsDefinition} and each of its methods * 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
+     * @JsDefinition
      * public interface GreeterJs extends Serializable {
      *     @JsExpression("window.alert($0)")
      *     void showGreeting(String greeting);
@@ -1975,17 +1975,17 @@ public PendingJavaScriptResult executeJs(String expression,
      *
      * 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.
-     * 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, + * declarations of every JavaScript definition 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. 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 JsCall}, 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 @@ -1995,40 +1995,42 @@ public PendingJavaScriptResult executeJs(String expression, * 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. + * The interface is checked when the implementation 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 + * the JavaScript definition type + * @param definitionType + * the JavaScript definition, 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 + * {@link JsDefinition}, or has a method that can not be + * answered */ @SuppressWarnings("unchecked") - public T executeJs(Class invokerType) { - Objects.requireNonNull(invokerType, "Invoker type cannot be null"); - if (!invokerType.isInterface()) { + public T executeJs(Class definitionType) { + Objects.requireNonNull(definitionType, + "Definition type cannot be null"); + if (!definitionType.isInterface()) { throw new IllegalArgumentException( - invokerType.getName() + " is not an interface"); + definitionType.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" + if (!definitionType.isAnnotationPresent(JsDefinition.class)) { + throw new IllegalArgumentException(definitionType.getName() + + " is not annotated with @JsDefinition, 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)); + checkDefinitionMethods(definitionType); + return (T) Proxy.newProxyInstance(definitionType.getClassLoader(), + new Class[] { definitionType }, + new JsDefinitionHandler(this, definitionType)); } /** - * 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 + * Checks the methods of a JavaScript definition: each one that has to be + * answered 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. @@ -2036,11 +2038,11 @@ public T executeJs(Class invokerType) { * 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) { + private static void checkDefinitionMethods(Class definitionType) { List undeclared = new ArrayList<>(); List unanswerable = new ArrayList<>(); List inJava = new ArrayList<>(); - for (Method method : invokerType.getMethods()) { + for (Method method : definitionType.getMethods()) { if (Modifier.isStatic(method.getModifiers())) { continue; } @@ -2063,7 +2065,7 @@ private static void checkInvokerMethods(Class invokerType) { } } if (!undeclared.isEmpty()) { - throw new IllegalArgumentException(invokerType.getName() + throw new IllegalArgumentException(definitionType.getName() + " declares no JavaScript to run for " + String.join(", ", undeclared) + ". Annotate the methods with @JsExpression, or make them" @@ -2071,17 +2073,17 @@ private static void checkInvokerMethods(Class invokerType) { + " 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." + throw new IllegalArgumentException(definitionType.getName() + + " has " + String.join(", ", unanswerable) + + " returning something that can not be answered with." + " A method returns void or PendingJavaScriptResult"); } if (!inJava.isEmpty() - && !Modifier.isPublic(invokerType.getModifiers())) { + && !Modifier.isPublic(definitionType.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) + throw new IllegalArgumentException(definitionType.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"); @@ -2089,11 +2091,13 @@ private static void checkInvokerMethods(Class invokerType) { } /** - * Turns a call on a JS invoker interface into a scheduled invocation that + * Turns a call on a JavaScript definition into a scheduled invocation that * carries the call. */ - private record JsInvokerHandler(Element element, - Class invokerType) implements InvocationHandler, Serializable { + private record JsDefinitionHandler(Element element, Class definitionType) + implements + InvocationHandler, + Serializable { @Override public Object invoke(Object proxy, Method method, Object[] args) @@ -2108,7 +2112,8 @@ public Object invoke(Object proxy, Method method, Object[] args) return InvocationHandler.invokeDefault(proxy, method, args); } catch (IllegalAccessException e) { throw new IllegalStateException("Cannot run " - + method.getName() + " of " + invokerType.getName() + + method.getName() + " of " + + definitionType.getName() + " in Java. Make the interface public, or declare" + " the JavaScript of the method with" + " @JsExpression", e); @@ -2118,9 +2123,8 @@ public Object invoke(Object proxy, Method method, Object[] args) .isAssignableFrom(PendingJavaScriptResult.class); List arguments = args == null ? List.of() : Arrays.asList(args); - PendingJavaScriptResult result = element - .scheduleInvokerCall(new JsInvokerCall(invokerType, - method.getName(), arguments)); + PendingJavaScriptResult result = element.scheduleJsCall( + new JsCall(definitionType, method.getName(), arguments)); return returnsResult ? result : null; } } @@ -2137,14 +2141,14 @@ private PendingJavaScriptResult scheduleExecuteJs(String expression, } /** - * 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 + * Schedules a call made through a JavaScript definition. 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) { + private PendingJavaScriptResult scheduleJsCall(JsCall call) { return scheduleJavaScriptInvocation(call, call.getExpression(), withElementAsLastParameter(call.arguments().toArray())); } @@ -2220,11 +2224,10 @@ public Registration addJsInitializer(String expression, } private PendingJavaScriptResult scheduleJavaScriptInvocation( - @Nullable JsInvokerCall invokerCall, String expression, - Object[] parameters) { + @Nullable JsCall jsCall, String expression, Object[] parameters) { StateNode node = getNode(); - JavaScriptInvocation invocation = new JavaScriptInvocation(invokerCall, + JavaScriptInvocation invocation = new JavaScriptInvocation(jsCall, expression, parameters); PendingJavaScriptInvocation pending = new PendingJavaScriptInvocation( 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..f38042fdd5d 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 @@ -216,10 +216,10 @@ public class FrontendUtils { /** * 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. + * {@code @JsDefinition} 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"; + public static final String JS_DEFINITIONS_FILE_NAME = "vaadin-js-definitions.js"; /** * File name of the index.html in client side. diff --git a/flow-server/src/main/java/com/vaadin/flow/js/JsInvokerCall.java b/flow-server/src/main/java/com/vaadin/flow/js/JsCall.java similarity index 78% rename from flow-server/src/main/java/com/vaadin/flow/js/JsInvokerCall.java rename to flow-server/src/main/java/com/vaadin/flow/js/JsCall.java index f0806a5ca6e..20ab55f0f1e 100644 --- a/flow-server/src/main/java/com/vaadin/flow/js/JsInvokerCall.java +++ b/flow-server/src/main/java/com/vaadin/flow/js/JsCall.java @@ -27,7 +27,7 @@ import com.vaadin.flow.dom.Element; /** - * A call made through {@link Element#executeJs(Class)}: which invoker + * A call made through {@link Element#executeJs(Class)}: which definition * interface, which method of it, and the arguments that were passed. *

* The call is what the client receives — the interface, the method and the @@ -38,34 +38,35 @@ * same interface with {@link #invokeOn(Object)} and let Java dispatch it: * *

- * if (call.invokerType() == FocusJs.class) {
+ * if (call.definitionType() == FocusJs.class) {
  *     call.invokeOn(new FocusSimulation(Element.get(pending.getOwner())));
  * }
  * 
* - * @param invokerType - * the invoker interface the call was made on + * @param definitionType + * the JavaScript definition the call was made on * @param methodName * the name of the called method * @param arguments * the arguments of the call, in declaration order, any of which may * be null */ -public record JsInvokerCall(Class invokerType, String methodName, +public record JsCall(Class definitionType, String methodName, List arguments) implements Serializable { /** - * Creates a call of the given method of the given invoker interface. + * Creates a call of the given method of the given JavaScript definition. * - * @param invokerType - * the invoker interface, not null + * @param definitionType + * the JavaScript definition, 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"); + public JsCall { + Objects.requireNonNull(definitionType, + "Definition type cannot be null"); Objects.requireNonNull(methodName, "Method name cannot be null"); // Copied rather than List.copyOf, which rejects a null element: an // argument may be null, and the client gets it as null @@ -73,18 +74,19 @@ public record JsInvokerCall(Class invokerType, String methodName, } /** - * Gets the identifier of the invoker interface, which is the key the + * Gets the identifier of the JavaScript definition, which is the key the * generated bundle registers its functions under. * - * @return the invoker identifier, not null + * @return the definition identifier, not null */ - public String getInvokerId() { - return invokerType.getName(); + public String getDefinitionId() { + return definitionType.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. + * Gets the identifier of the called method within its definition, which is + * the method name and the number of arguments, so that overloads stay + * apart. * * @return the method identifier, not null */ @@ -122,31 +124,31 @@ public String getExpression() { .getAnnotation(JsExpression.class); if (annotation == null) { throw new IllegalStateException( - "Method " + methodName + " of " + invokerType.getName() + "Method " + methodName + " of " + definitionType.getName() + " is not annotated with @JsExpression"); } return annotation.value(); } /** - * Runs this call on an implementation of the invoker interface, which is - * how a driver of the client side reproduces it without running the + * Runs this call on an implementation of the JavaScript definition, which + * is how a driver of the client side reproduces it without running the * JavaScript. * * @param implementation - * an implementation of {@link #invokerType()}, not + * an implementation of {@link #definitionType()}, not * null * @return the value returned by the implementation, or null * for a void method * @throws IllegalArgumentException * if the implementation does not implement - * {@link #invokerType()} + * {@link #definitionType()} */ public Object invokeOn(Object implementation) { - if (!invokerType.isInstance(implementation)) { + if (!definitionType.isInstance(implementation)) { throw new IllegalArgumentException( implementation.getClass().getName() + " does not implement " - + invokerType.getName()); + + definitionType.getName()); } try { return resolveMethod().invoke(implementation, arguments.toArray()); @@ -170,14 +172,14 @@ public Object invokeOn(Object implementation) { * limitation of the prototype rather than of the idea. */ private Method resolveMethod() { - List candidates = Arrays.stream(invokerType.getMethods()) + List candidates = Arrays.stream(definitionType.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 " + + " parameters in " + definitionType.getName() + ", found " + candidates.size()); } return candidates.get(0); diff --git a/flow-server/src/main/java/com/vaadin/flow/js/JsInvoker.java b/flow-server/src/main/java/com/vaadin/flow/js/JsDefinition.java similarity index 98% rename from flow-server/src/main/java/com/vaadin/flow/js/JsInvoker.java rename to flow-server/src/main/java/com/vaadin/flow/js/JsDefinition.java index 66726d23681..05fa79f926a 100644 --- a/flow-server/src/main/java/com/vaadin/flow/js/JsInvoker.java +++ b/flow-server/src/main/java/com/vaadin/flow/js/JsDefinition.java @@ -40,5 +40,5 @@ @Documented @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) -public @interface JsInvoker { +public @interface JsDefinition { } 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 5d230204bda..a67a11ae599 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 @@ -24,13 +24,13 @@ import com.vaadin.flow.dom.Element; /** - * The JavaScript that a method of a JS invoker interface runs, as a constant + * The JavaScript that a method of a JavaScript definition runs, as a constant * expression. *

* 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 + * $0, $1, …, and the element the definition + * 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. * 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..cdc2cc5d6ea 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 @@ -56,7 +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.js.JsCall; import com.vaadin.flow.server.DependencyFilter; import com.vaadin.flow.server.SystemMessages; import com.vaadin.flow.server.VaadinService; @@ -331,9 +331,9 @@ private static ReturnChannelRegistration createReturnValueChannel( private static ArrayNode encodeExecuteJavaScript( PendingJavaScriptInvocation invocation) { - JsInvokerCall invokerCall = invocation.getInvocation().getInvokerCall(); - if (invokerCall != null) { - return encodeInvokerCall(invocation, invokerCall); + JsCall jsCall = invocation.getInvocation().getJsCall(); + if (jsCall != null) { + return encodeJsCall(invocation, jsCall); } List parametersList = invocation.getInvocation() @@ -385,9 +385,9 @@ private static ArrayNode encodeExecuteJavaScript( } /** - * Encodes a call made through a JS invoker as + * Encodes a call made through a JavaScript definition as * [argument1, ..., element, successChannel, errorChannel, target], - * where the trailing target object names the invoker interface and the + * where the trailing target object names the JavaScript definition 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. @@ -397,15 +397,17 @@ private static ArrayNode encodeExecuteJavaScript( * 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) { + private static ArrayNode encodeJsCall( + PendingJavaScriptInvocation invocation, JsCall 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, + target.put(JsonConstants.UIDL_KEY_JS_DEFINITION, + call.getDefinitionId()); + target.put(JsonConstants.UIDL_KEY_JS_DEFINITION_METHOD, + call.getMethodId()); + target.put(JsonConstants.UIDL_KEY_JS_DEFINITION_ARGUMENTS, call.arguments().size()); if (invocation.isSubscribed()) { @@ -419,7 +421,7 @@ private static ArrayNode encodeInvokerCall( parameters = Stream.concat(parameters, Stream.of(successChannel, errorChannel)); - target.put(JsonConstants.UIDL_KEY_INVOKER_RETURNS, true); + target.put(JsonConstants.UIDL_KEY_JS_DEFINITION_RETURNS, true); } return Stream.concat(parameters.map(JacksonCodec::encodeWithTypeInfo), 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..9729ef8da04 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 @@ -166,29 +166,29 @@ 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 + * Key of the JavaScript definition in the target object that ends such an * invocation in UIDL messages, in place of a JavaScript expression. */ - public static final String UIDL_KEY_INVOKER = "invoker"; + public static final String UIDL_KEY_JS_DEFINITION = "definition"; /** * Key of the invoked method, as its name and argument count, in the target - * object of a JS invoker invocation. + * object of a JS invocation of declared JavaScript. */ - public static final String UIDL_KEY_INVOKER_METHOD = "method"; + public static final String UIDL_KEY_JS_DEFINITION_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. + * invocation of declared JavaScript. The parameter after them is the + * element to apply the function to. */ - public static final String UIDL_KEY_INVOKER_ARGUMENTS = "arguments"; + public static final String UIDL_KEY_JS_DEFINITION_ARGUMENTS = "arguments"; /** - * Key that marks a JS invoker invocation whose two last parameters are the - * channels for its return value. + * Key that marks a JS invocation of declared JavaScript whose two last + * parameters are the channels for its return value. */ - public static final String UIDL_KEY_INVOKER_RETURNS = "returns"; + public static final String UIDL_KEY_JS_DEFINITION_RETURNS = "returns"; /** * Key used to hold the feature id when synchronizing node values. diff --git a/flow-server/src/main/resources/vite.generated.ts b/flow-server/src/main/resources/vite.generated.ts index 527a479e774..f15dece4a7a 100644 --- a/flow-server/src/main/resources/vite.generated.ts +++ b/flow-server/src/main/resources/vite.generated.ts @@ -137,12 +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 +// The JavaScript declared by the @JsDefinition 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 +// definitions 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 jsDefinitionsFile = path.resolve(frontendFolder, settings.generatedFolder, 'vaadin-js-definitions.js'); +const hasJsDefinitions = existsSync(jsDefinitionsFile); // The browsers that Vaadin supports: Chrome, Edge and Firefox evergreen at the // versions current today, Firefox ESR, and Safari 17 in its latest minor @@ -334,9 +334,9 @@ 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') + if (hasJsDefinitions) { + const fileBuffer = readFileSync(jsDefinitionsFile, { encoding: 'utf-8' }).replace(/\r\n/g, '\n'); + frontendFiles[settings.generatedFolder + '/vaadin-js-definitions.js'] = createHash('sha256') .update(fileBuffer, 'utf8') .digest('hex'); } 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 6fef3d25846..a4c111bf385 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 @@ -26,7 +26,7 @@ import com.vaadin.flow.component.FocusOption.PreventScroll; import com.vaadin.flow.component.internal.PendingJavaScriptInvocation; import com.vaadin.flow.dom.Element; -import com.vaadin.flow.js.JsInvokerCall; +import com.vaadin.flow.js.JsCall; import com.vaadin.tests.util.MockUI; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -274,12 +274,12 @@ void focus_withoutOptions_generatesCorrectJS() { } @Test - void focus_invocationCarriesTheInvokerCallWithTheOptions() { + void focus_invocationCarriesTheDefinitionCallWithTheOptions() { ui.add(component); component.focus(PreventScroll.ENABLED); - JsInvokerCall call = dumpSingleCall(); - assertEquals(Focusable.FocusJs.class, call.invokerType()); + JsCall call = dumpSingleCall(); + assertEquals(Focusable.FocusJs.class, call.definitionType()); assertEquals("focus", call.methodName()); assertEquals("{\"preventScroll\":true}", call.arguments().get(0).toString(), @@ -292,7 +292,7 @@ void focusWithoutOptions_invocationCarriesTheNoArgumentCall() { component.focus(); assertEquals( - new JsInvokerCall(Focusable.FocusJs.class, "focus", + new JsCall(Focusable.FocusJs.class, "focus", Collections.singletonList(null)), dumpSingleCall(), "no options is the options of the browser, which is what it makes of none"); @@ -303,13 +303,12 @@ void blur_invocationCarriesTheBlurCall() { ui.add(component); component.blur(); - assertEquals( - new JsInvokerCall(Focusable.FocusJs.class, "blur", List.of()), + assertEquals(new JsCall(Focusable.FocusJs.class, "blur", List.of()), dumpSingleCall()); } @Test - void pendingInvocations_runOnAnImplementationOfTheInvoker_plainJavaScriptLeftIntact() { + void pendingInvocations_runOnAnImplementationOfTheDefinition_plainJavaScriptLeftIntact() { ui.add(component); component.focus(PreventScroll.ENABLED); component.getElement().executeJs("this.scrollTop = 0"); @@ -317,13 +316,14 @@ void pendingInvocations_runOnAnImplementationOfTheInvoker_plainJavaScriptLeftInt // What a driver of the client side that cannot run JavaScript does: // take the queue once, in order, and let Java dispatch the calls it - // recognizes onto its own implementation of the invoker interface + // recognizes onto its own implementation of the JavaScript definition List log = new ArrayList<>(); List unhandledJs = new ArrayList<>(); for (PendingJavaScriptInvocation pending : ui .dumpPendingJsInvocations()) { - JsInvokerCall call = pending.getInvocation().getInvokerCall(); - if (call != null && call.invokerType() == Focusable.FocusJs.class) { + JsCall call = pending.getInvocation().getJsCall(); + if (call != null + && call.definitionType() == Focusable.FocusJs.class) { call.invokeOn(new FocusSimulation( Element.get(pending.getOwner()), log)); } else { @@ -343,11 +343,11 @@ void pendingInvocations_runOnAnImplementationOfTheInvoker_plainJavaScriptLeftInt "the unhandled invocation should be the application JavaScript"); } - private JsInvokerCall dumpSingleCall() { + private JsCall dumpSingleCall() { List invocations = ui .dumpPendingJsInvocations(); assertEquals(1, invocations.size()); - return invocations.get(0).getInvocation().getInvokerCall(); + return invocations.get(0).getInvocation().getJsCall(); } /** 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 0c240d8db91..a9b75202756 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,9 +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.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 com.vaadin.flow.server.ErrorEvent; import com.vaadin.flow.server.MockVaadinServletService; import com.vaadin.flow.server.StreamResource; @@ -2666,7 +2666,7 @@ void callFunctionOnSubProperty() { } @Test - void executeJsWithInvoker_schedulesTheDeclaredExpressionAndCarriesTheCall() { + void executeJsWithDefinition_schedulesTheDeclaredExpressionAndCarriesTheCall() { UI ui = new MockUI(); Element element = ElementFactory.createDiv(); ui.getElement().appendChild(element); @@ -2683,12 +2683,12 @@ void executeJsWithInvoker_schedulesTheDeclaredExpressionAndCarriesTheCall() { "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()); + assertEquals(new JsCall(TestJs.class, "method", List.of("foo")), + invocation.getJsCall()); } @Test - void executeJsWithInvoker_interfaceWithoutAnnotation_throws() { + void executeJsWithDefinition_interfaceWithoutAnnotation_throws() { Element element = ElementFactory.createDiv(); assertThrows(IllegalArgumentException.class, @@ -2697,25 +2697,25 @@ void executeJsWithInvoker_interfaceWithoutAnnotation_throws() { } @Test - void executeJsWithInvoker_notAnInterface_throws() { + void executeJsWithDefinition_notAnInterface_throws() { Element element = ElementFactory.createDiv(); assertThrows(IllegalArgumentException.class, () -> element.executeJs(ElementTest.class), - "only an interface can declare invoker methods"); + "only an interface can declare JavaScript methods"); } @Test - void executeJsWithInvoker_methodReturningAResult_schedulesAndReturnsIt() { + void executeJsWithDefinition_methodReturningAResult_schedulesAndReturnsIt() { UI ui = new MockUI(); Element element = ElementFactory.createDiv(); ui.getElement().appendChild(element); - ResultJs invoker = element.executeJs(ResultJs.class); - assertNotNull(invoker.toString(), - "the invoker should answer the methods of Object"); + ResultJs resultJs = element.executeJs(ResultJs.class); + assertNotNull(resultJs.toString(), + "the implementation should answer the methods of Object"); - PendingJavaScriptResult result = invoker.readValue(); + PendingJavaScriptResult result = resultJs.readValue(); ui.getInternals().getStateTree().runExecutionsBeforeClientResponse(); assertNotNull(result, @@ -2725,13 +2725,13 @@ void executeJsWithInvoker_methodReturningAResult_schedulesAndReturnsIt() { } @Test - void executeJsWithInvoker_methodWithAnotherReturnType_throws() { + void executeJsWithDefinition_methodWithAnotherReturnType_throws() { Element element = ElementFactory.createDiv(); IllegalArgumentException exception = assertThrows( IllegalArgumentException.class, () -> element.executeJs(UnsupportedJs.class), - "a method the invoker can not answer should be refused when the invoker is handed out"); + "a method that can not be answered should be refused when the implementation is handed out"); assertTrue(exception.getMessage().contains("readValue"), "the message should name the method: " @@ -2739,7 +2739,7 @@ void executeJsWithInvoker_methodWithAnotherReturnType_throws() { } @Test - void executeJsWithInvoker_methodWithoutDeclaredJavaScript_throws() { + void executeJsWithDefinition_methodWithoutDeclaredJavaScript_throws() { Element element = ElementFactory.createDiv(); IllegalArgumentException exception = assertThrows( @@ -2752,20 +2752,21 @@ void executeJsWithInvoker_methodWithoutDeclaredJavaScript_throws() { } @Test - void executeJsWithInvoker_defaultAndStaticMethods_areNotDeclarations() { + void executeJsWithDefinition_defaultAndStaticMethods_areNotDeclarations() { Element element = ElementFactory.createDiv(); - ComposingJs invoker = element.executeJs(ComposingJs.class); + ComposingJs composingJs = 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 + // A static method belongs to the interface, not to the + // implementation, a default method answers with whatever Java answers + // with assertEquals("ComposingJs", ComposingJs.name()); - assertEquals("composing", invoker.describe(), + assertEquals("composing", composingJs.describe(), "a default method is not bound by what a declared one may return"); } @Test - void executeJsWithInvoker_defaultMethodOnANonPublicInterface_throws() { + void executeJsWithDefinition_defaultMethodOnANonPublicInterface_throws() { Element element = ElementFactory.createDiv(); IllegalArgumentException exception = assertThrows( @@ -2778,7 +2779,7 @@ void executeJsWithInvoker_defaultMethodOnANonPublicInterface_throws() { } @Test - void executeJsWithInvoker_defaultMethodDeclaringJavaScript_throws() { + void executeJsWithDefinition_defaultMethodDeclaringJavaScript_throws() { Element element = ElementFactory.createDiv(); assertThrows(IllegalArgumentException.class, @@ -2787,7 +2788,7 @@ void executeJsWithInvoker_defaultMethodDeclaringJavaScript_throws() { } @Test - void executeJsWithInvoker_defaultMethod_runsInJavaAndSchedulesWhatItCalls() { + void executeJsWithDefinition_defaultMethod_runsInJavaAndSchedulesWhatItCalls() { UI ui = new MockUI(); Element element = ElementFactory.createDiv(); ui.getElement().appendChild(element); @@ -2799,29 +2800,28 @@ void executeJsWithInvoker_defaultMethod_runsInJavaAndSchedulesWhatItCalls() { .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()); + assertEquals(new JsCall(ComposingJs.class, "method", List.of("foo")), + pendingJs.get(0).getInvocation().getJsCall()); } - @JsInvoker + @JsDefinition interface ResultJs extends Serializable { @JsExpression("return this.value;") PendingJavaScriptResult readValue(); } - @JsInvoker + @JsDefinition interface UnsupportedJs extends Serializable { @JsExpression("return this.value;") String readValue(); } - @JsInvoker + @JsDefinition interface UndeclaredJs extends Serializable { void undeclared(); } - @JsInvoker + @JsDefinition public interface ComposingJs extends Serializable { @JsExpression("this.method($0)") void method(String value); @@ -2840,7 +2840,7 @@ static String name() { } } - @JsInvoker + @JsDefinition interface NotPublicJs extends Serializable { @JsExpression("this.method()") void method(); @@ -2851,14 +2851,14 @@ default void twice() { } } - @JsInvoker + @JsDefinition interface ContradictoryJs extends Serializable { @JsExpression("this.method()") default void method() { } } - @JsInvoker + @JsDefinition interface TestJs extends Serializable { @JsExpression("this.method($0)") void method(String value); diff --git a/flow-server/src/test/java/com/vaadin/flow/js/JsInvokerCallTest.java b/flow-server/src/test/java/com/vaadin/flow/js/JsCallTest.java similarity index 92% rename from flow-server/src/test/java/com/vaadin/flow/js/JsInvokerCallTest.java rename to flow-server/src/test/java/com/vaadin/flow/js/JsCallTest.java index 6b7cec44f90..d8e699e8381 100644 --- a/flow-server/src/test/java/com/vaadin/flow/js/JsInvokerCallTest.java +++ b/flow-server/src/test/java/com/vaadin/flow/js/JsCallTest.java @@ -26,9 +26,9 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; -class JsInvokerCallTest { +class JsCallTest { - @JsInvoker + @JsDefinition interface GreeterJs extends Serializable { @JsExpression("window.alert($0)") void showGreeting(String greeting); @@ -64,16 +64,16 @@ public void ambiguous(int value) { } } - private static JsInvokerCall call(String methodName, Object... arguments) { - return new JsInvokerCall(GreeterJs.class, methodName, + private static JsCall call(String methodName, Object... arguments) { + return new JsCall(GreeterJs.class, methodName, Arrays.asList(arguments)); } @Test void identifiers_nameTheInterfaceAndTheMethodWithItsArity() { - JsInvokerCall call = call("showGreeting", "Hello"); + JsCall call = call("showGreeting", "Hello"); - assertEquals(GreeterJs.class.getName(), call.getInvokerId()); + assertEquals(GreeterJs.class.getName(), call.getDefinitionId()); assertEquals("showGreeting/1", call.getMethodId()); } @@ -110,7 +110,7 @@ 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); + JsCall call = call("showGreeting", (Object) null); Greeter greeter = new Greeter(); assertEquals(Collections.singletonList(null), call.arguments()); 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..abea9b48135 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 @@ -51,9 +51,9 @@ import com.vaadin.flow.internal.BundleUtils; import com.vaadin.flow.internal.JacksonUtils; import com.vaadin.flow.internal.StateTree; +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 com.vaadin.flow.router.ParentLayout; import com.vaadin.flow.router.Route; import com.vaadin.flow.router.RouteConfiguration; @@ -206,11 +206,10 @@ void testEncodeExecuteJavaScript_npmMode() { } @Test - void encodeExecuteJavaScript_invokerCall_sendsTheTargetInsteadOfTheScript() { + void encodeExecuteJavaScript_jsCall_sendsTheTargetInsteadOfTheScript() { Element element = ElementFactory.createDiv(); - JsInvokerCall call = new JsInvokerCall(TestJs.class, "method", - List.of("foo")); + JsCall call = new JsCall(TestJs.class, "method", List.of("foo")); JavaScriptInvocation invocation = new JavaScriptInvocation(call, call.getExpression(), "foo", element); @@ -219,7 +218,7 @@ void encodeExecuteJavaScript_invokerCall_sendsTheTargetInsteadOfTheScript() { invocation))); ObjectNode target = JacksonUtils.createObjectNode(); - target.put("invoker", TestJs.class.getName()); + target.put("definition", TestJs.class.getName()); target.put("method", "method/1"); target.put("arguments", 1); ArrayNode expectedJson = JacksonUtils.createArray( @@ -228,16 +227,15 @@ void encodeExecuteJavaScript_invokerCall_sendsTheTargetInsteadOfTheScript() { JacksonUtils.nullNode(), target)); assertTrue(JacksonUtils.jsonEquals(expectedJson, json), - "an invoker call should carry its target, and no JavaScript: " + "a call of declared JavaScript should carry its target, and no JavaScript: " + json); } @Test - void encodeExecuteJavaScript_subscribedInvokerCall_addsTheReturnChannels() { + void encodeExecuteJavaScript_subscribedDefinitionCall_addsTheReturnChannels() { Element element = ElementFactory.createDiv(); - JsInvokerCall call = new JsInvokerCall(TestJs.class, "method", - List.of("foo")); + JsCall call = new JsCall(TestJs.class, "method", List.of("foo")); JavaScriptInvocation invocation = new JavaScriptInvocation(call, call.getExpression(), "foo", element); PendingJavaScriptInvocation pending = new PendingJavaScriptInvocation( @@ -258,7 +256,7 @@ void encodeExecuteJavaScript_subscribedInvokerCall_addsTheReturnChannels() { assertEquals(1, target.get("arguments").asInt()); } - @JsInvoker + @JsDefinition interface TestJs extends Serializable { @JsExpression("this.method($0)") void method(String value); 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 349830e275e..5fb2e7eb798 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 @@ -63,9 +63,9 @@ import com.vaadin.flow.internal.DevModeHandler; import com.vaadin.flow.internal.DevModeHandlerManager; import com.vaadin.flow.internal.ThemeUtils; +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 com.vaadin.flow.server.VaadinService; import com.vaadin.flow.theme.Theme; @@ -1377,19 +1377,20 @@ static String frontendDependencies(Class type) { + ":" + annotation.themeFor()); } } - // The JavaScript an invoker interface declares is generated into the + // The JavaScript a JavaScript definition 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)) { + if (type.isAnnotationPresent(JsDefinition.class)) { for (Method method : type.getMethods()) { JsExpression expression = method .getAnnotation(JsExpression.class); if (expression != null) { - imports.add("jsinvoker:" - + JsInvokerCall.methodId(method.getName(), + imports.add("jsdefinition:" + + JsCall.methodId(method.getName(), method.getParameterCount()) + ":" + expression.value()); } 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/JsDefinitionHotswapper.java similarity index 77% rename from vaadin-dev-server/src/main/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapper.java rename to vaadin-dev-server/src/main/java/com/vaadin/base/devserver/hotswap/impl/JsDefinitionHotswapper.java index 5be9f3a74ee..63435489b83 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/JsDefinitionHotswapper.java @@ -24,20 +24,20 @@ import com.vaadin.base.devserver.hotswap.VaadinHotswapper; import com.vaadin.flow.di.Lookup; import com.vaadin.flow.internal.FrontendUtils; +import com.vaadin.flow.js.JsDefinition; 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; -import com.vaadin.flow.server.frontend.TaskGenerateJsInvokers; +import com.vaadin.flow.server.frontend.TaskGenerateJsDefinitions; import com.vaadin.flow.server.startup.ApplicationConfiguration; /** - * Reports a {@link JsInvoker} interface whose JavaScript the frontend bundle + * Reports a {@link JsDefinition} interface whose JavaScript the frontend bundle * does not carry. *

- * The JavaScript an invoker method declares with {@link JsExpression} is + * The JavaScript a definition 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 @@ -58,21 +58,21 @@ *

* For internal use only. May be renamed or removed in a future release. */ -public class JsInvokerHotswapper implements VaadinHotswapper { +public class JsDefinitionHotswapper implements VaadinHotswapper { @Override public void onClassesChange(HotswapClassEvent event) { - List> invokers = event.getChangedClasses().stream() - .filter(type -> type.isAnnotationPresent(JsInvoker.class)) + List> definitions = event.getChangedClasses().stream() + .filter(type -> type.isAnnotationPresent(JsDefinition.class)) .toList(); - if (invokers.isEmpty()) { + if (definitions.isEmpty()) { return; } VaadinService service = event.getVaadinService(); Options options = buildOptions(service); - List> stale = TaskGenerateJsInvokers - .missingFromGeneratedFile(options, invokers); + List> stale = TaskGenerateJsDefinitions + .missingFromGeneratedFile(options, definitions); if (stale.isEmpty()) { return; } @@ -84,8 +84,8 @@ public void onClassesChange(HotswapClassEvent event) { return; } - List> unresolved = TaskGenerateJsInvokers - .updateJsInvokers(options, invokers); + List> unresolved = TaskGenerateJsDefinitions + .updateJsDefinitions(options, definitions); if (unresolved.isEmpty()) { getLogger().debug( "Wrote the JavaScript declared by {}, which the frontend dev server replaces in the browser", @@ -115,8 +115,8 @@ private static Options buildOptions(VaadinService service) { FrontendUtils.getProjectFrontendDir(configuration)); } - private static List names(List> invokers) { - return invokers.stream().map(Class::getName).toList(); + private static List names(List> definitions) { + return definitions.stream().map(Class::getName).toList(); } /** @@ -124,17 +124,17 @@ private static List names(List> invokers) { *

* 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 + * @param definitionNames + * the names of the JavaScript definitions to report, never empty */ - void report(List invokerNames) { + void report(List definitionNames) { 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 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)); + + "It is collected into the bundle when the frontend is built, so a call made through the definition keeps running the previous version, or finds no function at all, until the application is restarted.", + String.join(", ", definitionNames)); } private static Logger getLogger() { - return LoggerFactory.getLogger(JsInvokerHotswapper.class); + return LoggerFactory.getLogger(JsDefinitionHotswapper.class); } } 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 793ec719fa5..f4f1cc49725 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 @@ -40,7 +40,7 @@ import com.vaadin.flow.di.Lookup; import com.vaadin.flow.internal.DevModeHandlerManager; import com.vaadin.flow.internal.Template; -import com.vaadin.flow.js.JsInvoker; +import com.vaadin.flow.js.JsDefinition; import com.vaadin.flow.router.HasErrorParameter; import com.vaadin.flow.router.Layout; import com.vaadin.flow.router.Route; @@ -74,7 +74,7 @@ Template.class, LoadDependenciesOnStartup.class, TypeScriptBootstrapModifier.class, DevToolsMessageHandler.class, Component.class, Layout.class, StyleSheet.class, - StyleSheet.Container.class, JsInvoker.class }) + StyleSheet.Container.class, JsDefinition.class }) @WebListener public class DevModeStartupListener implements VaadinServletContextStartupInitializer, Serializable, 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 ef80b5b08bf..bfbb214854a 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,4 +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 +com.vaadin.base.devserver.hotswap.impl.JsDefinitionHotswapper 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 13a625c3c7c..b3b05881ab3 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.js.JsDefinition; 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; @@ -163,13 +163,13 @@ static class SomeView extends Component { static class NothingDeclared { } - @JsInvoker + @JsDefinition interface GreeterJs extends Serializable { @JsExpression("window.alert($0)") void showGreeting(String greeting); } - @JsInvoker + @JsDefinition interface EditedGreeterJs extends Serializable { @JsExpression("window.alert('edited ' + $0)") void showGreeting(String greeting); @@ -192,13 +192,13 @@ void frontendDependencies_seesTheThemeOnAnAppShellThatIsNoComponent() { } @Test - void frontendDependencies_seesTheJavaScriptAnInvokerDeclares() { + void frontendDependencies_seesTheJavaScriptADefinitionDeclares() { // 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); + assertTrue(imports.contains("jsdefinition:showGreeting/1"), imports); // An edited expression keeps the same method, so the expression itself // has to be part of the comparison. assertNotEquals(imports, 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/JsDefinitionHotswapperTest.java similarity index 51% rename from vaadin-dev-server/src/test/java/com/vaadin/base/devserver/hotswap/impl/JsInvokerHotswapperTest.java rename to vaadin-dev-server/src/test/java/com/vaadin/base/devserver/hotswap/impl/JsDefinitionHotswapperTest.java index 2ee6cc79e71..68d7600c1c4 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/JsDefinitionHotswapperTest.java @@ -30,12 +30,14 @@ import org.mockito.Mockito; import com.vaadin.base.devserver.hotswap.HotswapClassEvent; +import com.vaadin.flow.di.Lookup; import com.vaadin.flow.internal.FrontendUtils; +import com.vaadin.flow.js.JsDefinition; 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; +import com.vaadin.flow.server.frontend.Options; +import com.vaadin.flow.server.frontend.TaskGenerateJsDefinitions; import com.vaadin.flow.server.startup.ApplicationConfiguration; import com.vaadin.flow.server.startup.ApplicationConfigurationFactory; import com.vaadin.tests.util.MockDeploymentConfiguration; @@ -43,30 +45,30 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; -class JsInvokerHotswapperTest { +class JsDefinitionHotswapperTest { - @JsInvoker + @JsDefinition interface GreeterJs extends Serializable { @JsExpression("window.alert($0); this.focus()") void showGreeting(String greeting); } - @JsInvoker + @JsDefinition interface CounterJs extends Serializable { @JsExpression("this.count = ($0 || 0) + 1") void count(Integer from); } - static class NotAnInvoker { + static class NotADefinition { } // Records what a change is reported for instead of logging it. - private static class TestHotswapper extends JsInvokerHotswapper { + private static class TestHotswapper extends JsDefinitionHotswapper { private final List reported = new ArrayList<>(); @Override - void report(List invokerNames) { - reported.addAll(invokerNames); + void report(List definitionNames) { + reported.addAll(definitionNames); } } @@ -109,29 +111,39 @@ private void withFrontendDevServer() { .thenReturn(Mode.DEVELOPMENT_FRONTEND_LIVERELOAD); } - private String readGeneratedInvokers() throws IOException { - return Files - .readString( - new File( - FrontendUtils.getFrontendGeneratedFolder( - frontendFolder), - FrontendUtils.JS_INVOKERS_FILE_NAME).toPath(), - StandardCharsets.UTF_8); + private String readGeneratedDefinitions() throws IOException { + return Files.readString( + new File( + FrontendUtils + .getFrontendGeneratedFolder(frontendFolder), + FrontendUtils.JS_DEFINITIONS_FILE_NAME).toPath(), + StandardCharsets.UTF_8); } - private void writeGeneratedInvokers(String content) throws IOException { + private void writeGeneratedDefinitions(String content) throws IOException { File generated = FrontendUtils .getFrontendGeneratedFolder(frontendFolder); generated.mkdirs(); Files.writeString( - new File(generated, FrontendUtils.JS_INVOKERS_FILE_NAME) + new File(generated, FrontendUtils.JS_DEFINITIONS_FILE_NAME) .toPath(), content, StandardCharsets.UTF_8); } - private String generatedFor(Class invoker) { - return String.join(System.lineSeparator(), - TaskGenerateJsInvokers.renderInvokerLines(invoker)); + /** + * The file as a build writes it for the given interface, which is what a + * browser would be running. + */ + private String generatedFor(Class definition) throws IOException { + Options options = new Options(Mockito.mock(Lookup.class), null, null) + .withFrontendDirectory(frontendFolder); + TaskGenerateJsDefinitions.updateJsDefinitions(options, + List.of(definition)); + String content = readGeneratedDefinitions(); + Files.delete(new File( + FrontendUtils.getFrontendGeneratedFolder(frontendFolder), + FrontendUtils.JS_DEFINITIONS_FILE_NAME).toPath()); + return content; } private void classesChanged(Class... classes) { @@ -140,56 +152,28 @@ private void classesChanged(Class... classes) { } @Test - void bundleCarriesTheDeclarations_nothingReported() throws IOException { - writeGeneratedInvokers(generatedFor(GreeterJs.class)); + void fileCarriesTheDeclarations_nothingReported() throws IOException { + writeGeneratedDefinitions(generatedFor(GreeterJs.class)); classesChanged(GreeterJs.class); assertTrue(hotswapper.reported.isEmpty(), - "a bundle built from these declarations runs exactly them: " + "a browser running what the interface declares needs nothing said about it: " + hotswapper.reported); } @Test - 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 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() { + void fileDoesNotCarryTheDeclarations_reported() { classesChanged(GreeterJs.class); assertEquals(List.of(GreeterJs.class.getName()), hotswapper.reported, - "without a generated file nothing carries the declarations"); + "without the dev server only a build can put them there, so say so"); } @Test - void frontendDevServerRunning_fileWrittenAgainInsteadOfReported() + void frontendDevServerRunning_appliedInsteadOfReported() throws IOException { - writeGeneratedInvokers(generatedFor(GreeterJs.class) + writeGeneratedDefinitions(generatedFor(GreeterJs.class) .replace("window.alert($0); this.focus()", "window.alert($0)")); withFrontendDevServer(); @@ -199,53 +183,17 @@ void frontendDevServerRunning_fileWrittenAgainInsteadOfReported() "with the dev server the change is applied, not reported: " + hotswapper.reported); assertTrue( - readGeneratedInvokers() + readGeneratedDefinitions() .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"); + "and what the browser reloads is what the interface declares now"); } @Test - void frontendDevServerRunningWithoutTheFile_fileWritten() - throws IOException { - withFrontendDevServer(); - - classesChanged(GreeterJs.class); - - assertTrue(hotswapper.reported.isEmpty()); - 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. + void frontendDevServerRunningButNothingCanBeWritten_reported() { + // A directory where the file belongs: the change cannot be applied, so + // it is reported rather than passing as applied new File(FrontendUtils.getFrontendGeneratedFolder(frontendFolder), - FrontendUtils.JS_INVOKERS_FILE_NAME).mkdirs(); + FrontendUtils.JS_DEFINITIONS_FILE_NAME).mkdirs(); withFrontendDevServer(); classesChanged(GreeterJs.class); @@ -254,10 +202,10 @@ void frontendDevServerRunningButFileNotWritable_reported() } @Test - void noInvokerChanged_nothingReported() throws IOException { - writeGeneratedInvokers(generatedFor(GreeterJs.class)); + void noDefinitionChanged_nothingReported() throws IOException { + writeGeneratedDefinitions(generatedFor(GreeterJs.class)); - classesChanged(NotAnInvoker.class); + classesChanged(NotADefinition.class); assertTrue(hotswapper.reported.isEmpty(), "a class that declares no JavaScript is not a frontend change"); 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 5e0aea61958..985cb15e218 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 @@ -38,7 +38,7 @@ import com.vaadin.flow.component.dependency.StyleSheet; import com.vaadin.flow.component.page.AppShellConfigurator; import com.vaadin.flow.internal.Template; -import com.vaadin.flow.js.JsInvoker; +import com.vaadin.flow.js.JsDefinition; import com.vaadin.flow.router.HasErrorParameter; import com.vaadin.flow.router.Layout; import com.vaadin.flow.router.Route; @@ -76,7 +76,7 @@ void applicableClasses_knownClasses() { Template.class, LoadDependenciesOnStartup.class, Component.class, TypeScriptBootstrapModifier.class, DevToolsMessageHandler.class, Layout.class, StyleSheet.class, - StyleSheet.Container.class, JsInvoker.class); + StyleSheet.Container.class, JsDefinition.class); for (Class clz : classes) { assertTrue(knownClasses.contains(clz), From 0b8780ef242b26751b19c92002c3c5ac908e68b1 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:14:48 +0000 Subject: [PATCH 37/57] test: cover a bundle that carries no hash for the generated file A bundle built before the application declared any JavaScript has no hash for the generated file at all, rather than one that differs, and compareFrontendHashes answers for the two separately. The case that checks a rebuild now runs for both, instead of only for a hash that does not match. Also pin that the generated file accepts its own update: without that block a rewrite while the application runs is ignored by the browser rather than replacing the module, and nothing else would notice. Make readDefinitionNames private, since reading the names back is only for rendering the file again from within this class. --- .../frontend/TaskGenerateJsDefinitions.java | 11 +++---- .../server/frontend/BundleValidationTest.java | 30 ++++++++++++++----- .../TaskGenerateJsDefinitionsTest.java | 10 +++++++ 3 files changed, 36 insertions(+), 15 deletions(-) diff --git a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitions.java b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitions.java index 423bee8f348..903701f10c0 100644 --- a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitions.java +++ b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitions.java @@ -241,19 +241,16 @@ private static Logger getLogger() { /** * Reads back the names of the JavaScript definitions a generated file - * registers, which is what a browser that has the file can run. - *

- * Exposed together with {@link #renderDefinitionLines(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. + * registers, which is what a browser that has the file can run. Reading the + * format back here keeps it next to {@link #renderDefinitionLines(Class)}, + * which writes 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 readDefinitionNames(String fileContent) { + private static List readDefinitionNames(String fileContent) { List names = new ArrayList<>(); if (fileContent == null) { return names; 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 76e98c0cd61..fd0a764a5d0 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 @@ -31,6 +31,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; import org.mockito.MockedStatic; import org.mockito.Mockito; @@ -1075,18 +1076,31 @@ void frontendFileHashMatches_noBundleRebuild(Mode mode) throws IOException { assertFalse(needsBuild, "Jar fronted file content hash should match."); } + static Stream modesAndBundleHashes() { + // What the stats say the bundle was built with: a hash the declarations + // do not produce, so it was built with another version of them, and no + // hash at all, as in a bundle built before any definition existed + return modes().flatMap(mode -> Stream.of( + Arguments.of(mode, + "not the hash of what the interfaces declare"), + Arguments.of(mode, null))); + } + @ParameterizedTest - @MethodSource("modes") - void jsDefinitionJavaScriptChanged_bundleRebuild(Mode mode) { + @MethodSource("modesAndBundleHashes") + void jsDefinitionJavaScriptNotInTheBundle_bundleRebuild(Mode mode, + String bundleHash) { 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_DEFINITIONS_FILE_NAME, - "not the hash of what the interfaces declare"); + ObjectNode hashes = (ObjectNode) stats.get(FRONTEND_HASHES); + String generatedFile = FrontendUtils.GENERATED + + FrontendUtils.JS_DEFINITIONS_FILE_NAME; + if (bundleHash == null) { + hashes.remove(generatedFile); + } else { + hashes.put(generatedFile, bundleHash); + } setupFrontendUtilsMock(stats); boolean needsBuild = BundleValidationUtil.needsBuild(options, diff --git a/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitionsTest.java b/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitionsTest.java index bc4ff80a57a..9a79c64b1d2 100644 --- a/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitionsTest.java +++ b/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitionsTest.java @@ -225,6 +225,16 @@ void updateJsDefinitions_writesWhatIsAskedForBesideWhatTheFileHolds() + written); } + @Test + void acceptsItsOwnUpdate() throws ExecutionFailedException { + task.execute(); + String content = task.getFileContent(); + + assertTrue(content.contains("import.meta.hot.accept()"), + "the file should accept its own update, or writing it again while the application runs is ignored by the browser instead of replacing the module: " + + content); + } + @Test void writesTheFileTheBootstrapImports() throws ExecutionFailedException { task.execute(); From c0601bb9e9b238eda048a956f9397dcfa8f7b317 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 13:02:18 +0000 Subject: [PATCH 38/57] refactor: ask one question per branch, and say what the methods do The hotswapper asked what the generated file is missing before deciding what to do about it, and then asked again by writing the file. Each branch now asks once: with the dev server, writing the file answers what could not be applied, and without it, reading the file answers what a build would have to put there. Rename what the two say they do: report becomes warnAboutMissingDefinitions and takes the interfaces rather than their names, and the task finds rather than is what is findMissingFromGeneratedFile. Drop tests that assert what another test already covers: the focus and blur payloads, which the case that dispatches them onto an implementation asserts end to end; a default method answering in Java, which the case that schedules what it calls now asserts too; and one of the two client cases for the parameter count guard, which is a single comparison in either direction. --- .../frontend/TaskGenerateJsDefinitions.java | 10 ++-- .../TaskGenerateJsDefinitionsTest.java | 13 ++--- .../flow/ExecuteJavaScriptProcessorTests.ts | 17 ++----- .../vaadin/flow/component/FocusableTest.java | 42 ---------------- .../java/com/vaadin/flow/dom/ElementTest.java | 21 +++----- .../hotswap/impl/JsDefinitionHotswapper.java | 49 +++++++++---------- .../impl/JsDefinitionHotswapperTest.java | 10 ++-- 7 files changed, 47 insertions(+), 115 deletions(-) diff --git a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitions.java b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitions.java index 903701f10c0..a9b5ce5c515 100644 --- a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitions.java +++ b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitions.java @@ -122,7 +122,7 @@ static String renderFileContent(Collection> 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, + public static List> findMissingFromGeneratedFile(Options options, Collection> definitions) { String generated = readGeneratedFile(options); return definitions.stream() @@ -158,7 +158,7 @@ public static List> updateJsDefinitions(Options options, Collection> definitions) { String generated = readGeneratedFile(options); String content = renderFileContent( - withDefinitionsOf(generated, definitions)); + mergeWithDefinitionsIn(generated, definitions)); TaskGenerateJsDefinitions task = new TaskGenerateJsDefinitions(options); try { @@ -200,7 +200,7 @@ private static boolean isInGeneratedFile(Class definition, * The given interfaces, plus the ones the content registers that are not * among them and can still be loaded. */ - private static Collection> withDefinitionsOf(String generated, + private static Collection> mergeWithDefinitionsIn(String generated, Collection> definitions) { Map> byName = new LinkedHashMap<>(); definitions.forEach( @@ -271,8 +271,8 @@ private static List readDefinitionNames(String fileContent) { * declares JavaScript, keyed by method name and argument count. *

* Package private: whether a file carries what an interface declares is - * answered by {@link #missingFromGeneratedFile(Options, Collection)}, which - * compares against this. + * answered by {@link #findMissingFromGeneratedFile(Options, Collection)}, + * which compares against this. * * @param definition * the JavaScript definition to render, not null diff --git a/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitionsTest.java b/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitionsTest.java index 9a79c64b1d2..07ca3e63409 100644 --- a/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitionsTest.java +++ b/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitionsTest.java @@ -164,7 +164,7 @@ void updateJsDefinitions_fileNotWritable_answersWithWhatItDoesNotCarry() } @Test - void missingFromGeneratedFile_answersForWhatTheFileCarries() + void findMissingFromGeneratedFile_answersForWhatTheFileCarries() throws ExecutionFailedException, IOException { task.execute(); File generated = new File( @@ -173,7 +173,7 @@ void missingFromGeneratedFile_answersForWhatTheFileCarries() String carried = Files.readString(generated.toPath()); assertTrue( - TaskGenerateJsDefinitions.missingFromGeneratedFile(options, + TaskGenerateJsDefinitions.findMissingFromGeneratedFile(options, List.of(GreeterJs.class)).isEmpty(), "the file was written from this interface"); @@ -183,7 +183,7 @@ void missingFromGeneratedFile_answersForWhatTheFileCarries() carried.replace("window.alert({ text: $0, kind: 'greeting' })", "window.alert($0)")); assertEquals(List.of(GreeterJs.class), - TaskGenerateJsDefinitions.missingFromGeneratedFile(options, + TaskGenerateJsDefinitions.findMissingFromGeneratedFile(options, List.of(GreeterJs.class)), "another version of the declarations is not the declarations"); @@ -192,13 +192,14 @@ void missingFromGeneratedFile_answersForWhatTheFileCarries() Files.writeString(generated.toPath(), carried .replace(GreeterJs.class.getName(), "com.example.RenamedJs")); assertEquals(List.of(GreeterJs.class), - TaskGenerateJsDefinitions.missingFromGeneratedFile(options, + TaskGenerateJsDefinitions.findMissingFromGeneratedFile(options, List.of(GreeterJs.class)), "a call looks the interface up by name, so the name is part of carrying it"); Files.delete(generated.toPath()); - assertEquals(List.of(GreeterJs.class), TaskGenerateJsDefinitions - .missingFromGeneratedFile(options, List.of(GreeterJs.class)), + assertEquals(List.of(GreeterJs.class), + TaskGenerateJsDefinitions.findMissingFromGeneratedFile(options, + List.of(GreeterJs.class)), "no file carries nothing"); } 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 c9ea7408019..9e5e82927f0 100644 --- a/flow-client/src/test/frontend/internal/client/flow/ExecuteJavaScriptProcessorTests.ts +++ b/flow-client/src/test/frontend/internal/client/flow/ExecuteJavaScriptProcessorTests.ts @@ -142,7 +142,9 @@ describe('ExecuteJavaScriptProcessor', () => { }); // One argument declared, but no element to apply the function to: the - // invocation and this client disagree about the signature. + // invocation and this client disagree about the signature, which is the + // same disagreement as an invocation that carries one parameter too + // many. processor().execute([['Hello', { definition: DEFINITION, method: 'showGreeting/1', arguments: 1 }]]); expect(calls).to.equal(0); @@ -172,19 +174,6 @@ describe('ExecuteJavaScriptProcessor', () => { expect(errors).to.have.lengthOf(1); }); - it('does not run a call that carries more parameters than the target declares', () => { - let calls = 0; - registerDefinition('showGreeting/1', () => { - calls += 1; - }); - - processor().execute([ - ['Hello', 'unexpected', { tagName: 'div' }, { definition: DEFINITION, 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/flow-server/src/test/java/com/vaadin/flow/component/FocusableTest.java b/flow-server/src/test/java/com/vaadin/flow/component/FocusableTest.java index a4c111bf385..386f9544b03 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,7 +16,6 @@ package com.vaadin.flow.component; import java.util.ArrayList; -import java.util.Collections; import java.util.List; import org.junit.jupiter.api.Test; @@ -273,40 +272,6 @@ void focus_withoutOptions_generatesCorrectJS() { assertNull(params.getFirst(), "Should pass no options"); } - @Test - void focus_invocationCarriesTheDefinitionCallWithTheOptions() { - ui.add(component); - component.focus(PreventScroll.ENABLED); - - JsCall call = dumpSingleCall(); - assertEquals(Focusable.FocusJs.class, call.definitionType()); - 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_invocationCarriesTheNoArgumentCall() { - ui.add(component); - component.focus(); - - assertEquals( - new JsCall(Focusable.FocusJs.class, "focus", - Collections.singletonList(null)), - dumpSingleCall(), - "no options is the options of the browser, which is what it makes of none"); - } - - @Test - void blur_invocationCarriesTheBlurCall() { - ui.add(component); - component.blur(); - - assertEquals(new JsCall(Focusable.FocusJs.class, "blur", List.of()), - dumpSingleCall()); - } - @Test void pendingInvocations_runOnAnImplementationOfTheDefinition_plainJavaScriptLeftIntact() { ui.add(component); @@ -343,13 +308,6 @@ void pendingInvocations_runOnAnImplementationOfTheDefinition_plainJavaScriptLeft "the unhandled invocation should be the application JavaScript"); } - private JsCall dumpSingleCall() { - List invocations = ui - .dumpPendingJsInvocations(); - assertEquals(1, invocations.size()); - return invocations.get(0).getInvocation().getJsCall(); - } - /** * What a browserless driver would register for {@link Focusable.FocusJs}: * the server-side effect of the operations, with no JavaScript involved. 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 a9b75202756..fcfb610c97f 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 @@ -2751,20 +2751,6 @@ void executeJsWithDefinition_methodWithoutDeclaredJavaScript_throws() { + exception.getMessage()); } - @Test - void executeJsWithDefinition_defaultAndStaticMethods_areNotDeclarations() { - Element element = ElementFactory.createDiv(); - - ComposingJs composingJs = element.executeJs(ComposingJs.class); - - // A static method belongs to the interface, not to the - // implementation, a default method answers with whatever Java answers - // with - assertEquals("ComposingJs", ComposingJs.name()); - assertEquals("composing", composingJs.describe(), - "a default method is not bound by what a declared one may return"); - } - @Test void executeJsWithDefinition_defaultMethodOnANonPublicInterface_throws() { Element element = ElementFactory.createDiv(); @@ -2793,7 +2779,8 @@ void executeJsWithDefinition_defaultMethod_runsInJavaAndSchedulesWhatItCalls() { Element element = ElementFactory.createDiv(); ui.getElement().appendChild(element); - element.executeJs(ComposingJs.class).twice("foo"); + ComposingJs composingJs = element.executeJs(ComposingJs.class); + composingJs.twice("foo"); ui.getInternals().getStateTree().runExecutionsBeforeClientResponse(); List pendingJs = ui.getInternals() @@ -2802,6 +2789,8 @@ void executeJsWithDefinition_defaultMethod_runsInJavaAndSchedulesWhatItCalls() { "a default method runs in Java, and what it calls of the interface is scheduled"); assertEquals(new JsCall(ComposingJs.class, "method", List.of("foo")), pendingJs.get(0).getInvocation().getJsCall()); + assertEquals("composing", composingJs.describe(), + "a default method answers in Java, so it is not bound by what a declared one may return"); } @JsDefinition @@ -2835,6 +2824,8 @@ default String describe() { return "composing"; } + // Declares no JavaScript and is not answered by the implementation, + // so handing one out has to leave it alone static String name() { return "ComposingJs"; } diff --git a/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/hotswap/impl/JsDefinitionHotswapper.java b/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/hotswap/impl/JsDefinitionHotswapper.java index 63435489b83..7638f39dcdf 100644 --- a/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/hotswap/impl/JsDefinitionHotswapper.java +++ b/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/hotswap/impl/JsDefinitionHotswapper.java @@ -16,6 +16,7 @@ package com.vaadin.base.devserver.hotswap.impl; import java.util.List; +import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -71,27 +72,23 @@ public void onClassesChange(HotswapClassEvent event) { VaadinService service = event.getVaadinService(); Options options = buildOptions(service); - List> stale = TaskGenerateJsDefinitions - .missingFromGeneratedFile(options, definitions); - if (stale.isEmpty()) { - return; - } - if (!canReplaceInTheBrowser(service)) { + List> missing; + if (canReplaceInTheBrowser(service)) { + // Writing the file again is what the browser runs afterwards, and + // the write leaves the file alone when nothing it holds changed, + // so what comes back is what could not be applied + missing = TaskGenerateJsDefinitions.updateJsDefinitions(options, + definitions); + } else { // What a browser has without the dev server is a bundle, which - // only a build produces - report(names(stale)); - return; + // only a build produces, so a change can only be reported + missing = TaskGenerateJsDefinitions + .findMissingFromGeneratedFile(options, definitions); } - List> unresolved = TaskGenerateJsDefinitions - .updateJsDefinitions(options, definitions); - if (unresolved.isEmpty()) { - getLogger().debug( - "Wrote the JavaScript declared by {}, which the frontend dev server replaces in the browser", - names(stale)); - } else { - report(names(unresolved)); + if (!missing.isEmpty()) { + warnAboutMissingDefinitions(missing); } } @@ -115,23 +112,21 @@ private static Options buildOptions(VaadinService service) { FrontendUtils.getProjectFrontendDir(configuration)); } - private static List names(List> definitions) { - return definitions.stream().map(Class::getName).toList(); - } - /** - * Says that the bundle does not carry what the given interfaces declare. + * Warns that the bundle does not carry what the given interfaces declare. *

- * Package-private so that what a change is reported for can be asserted. + * Package-private so that what a change is warned about can be asserted. * - * @param definitionNames - * the names of the JavaScript definitions to report, never empty + * @param definitions + * the JavaScript definitions the bundle does not carry, never + * empty */ - void report(List definitionNames) { + void warnAboutMissingDefinitions(List> definitions) { 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 a call made through the definition keeps running the previous version, or finds no function at all, until the application is restarted.", - String.join(", ", definitionNames)); + definitions.stream().map(Class::getName) + .collect(Collectors.joining(", "))); } private static Logger getLogger() { diff --git a/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/hotswap/impl/JsDefinitionHotswapperTest.java b/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/hotswap/impl/JsDefinitionHotswapperTest.java index 68d7600c1c4..400001ccf32 100644 --- a/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/hotswap/impl/JsDefinitionHotswapperTest.java +++ b/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/hotswap/impl/JsDefinitionHotswapperTest.java @@ -62,13 +62,13 @@ interface CounterJs extends Serializable { static class NotADefinition { } - // Records what a change is reported for instead of logging it. + // Records what a change is warned about instead of logging it. private static class TestHotswapper extends JsDefinitionHotswapper { private final List reported = new ArrayList<>(); @Override - void report(List definitionNames) { - reported.addAll(definitionNames); + void warnAboutMissingDefinitions(List> definitions) { + definitions.stream().map(Class::getName).forEach(reported::add); } } @@ -202,9 +202,7 @@ void frontendDevServerRunningButNothingCanBeWritten_reported() { } @Test - void noDefinitionChanged_nothingReported() throws IOException { - writeGeneratedDefinitions(generatedFor(GreeterJs.class)); - + void noDefinitionChanged_nothingReported() { classesChanged(NotADefinition.class); assertTrue(hotswapper.reported.isEmpty(), From e2b45168fe94937a43940fd763f3d15b5deeccc6 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 13:08:07 +0000 Subject: [PATCH 39/57] test: pin that an unchanged declaration leaves the generated file alone With the frontend dev server the file is now written again for every redefinition of a declaring interface, and what keeps a save that changed no JavaScript from replacing the module in every browser is that the write leaves a file alone when its content would not change. Nothing asserted that. --- .../impl/JsDefinitionHotswapperTest.java | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/hotswap/impl/JsDefinitionHotswapperTest.java b/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/hotswap/impl/JsDefinitionHotswapperTest.java index 400001ccf32..06fbfca4980 100644 --- a/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/hotswap/impl/JsDefinitionHotswapperTest.java +++ b/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/hotswap/impl/JsDefinitionHotswapperTest.java @@ -188,6 +188,27 @@ void frontendDevServerRunning_appliedInsteadOfReported() "and what the browser reloads is what the interface declares now"); } + @Test + void frontendDevServerRunningAndFileUpToDate_fileLeftAlone() + throws IOException { + writeGeneratedDefinitions(generatedFor(GreeterJs.class)); + File generated = new File( + FrontendUtils.getFrontendGeneratedFolder(frontendFolder), + FrontendUtils.JS_DEFINITIONS_FILE_NAME); + // A moment in the past, so that a write of the same content shows + generated.setLastModified(System.currentTimeMillis() - 60_000); + long untouched = generated.lastModified(); + withFrontendDevServer(); + + classesChanged(GreeterJs.class); + + assertTrue(hotswapper.reported.isEmpty(), + "the browser is running what the interface declares: " + + hotswapper.reported); + assertEquals(untouched, generated.lastModified(), + "a redefinition that changes no JavaScript should leave the file alone, or the dev server replaces the module in every browser for nothing"); + } + @Test void frontendDevServerRunningButNothingCanBeWritten_reported() { // A directory where the file belongs: the change cannot be applied, so From 3379725fb90c9becf765329aedb4f21adae941af 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 16:06:27 +0000 Subject: [PATCH 40/57] test: write the generated file once, where the hotswapper reads it A case wrote the file through the task, read it back, deleted it and wrote the same content again. Writing it through the task is already writing it where the hotswapper looks, so that is all a case does now, and the one that needs an older version of the declarations edits the file it wrote. --- .../impl/JsDefinitionHotswapperTest.java | 70 +++++++++---------- 1 file changed, 32 insertions(+), 38 deletions(-) diff --git a/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/hotswap/impl/JsDefinitionHotswapperTest.java b/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/hotswap/impl/JsDefinitionHotswapperTest.java index 06fbfca4980..6d9a8eec6b5 100644 --- a/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/hotswap/impl/JsDefinitionHotswapperTest.java +++ b/vaadin-dev-server/src/test/java/com/vaadin/base/devserver/hotswap/impl/JsDefinitionHotswapperTest.java @@ -111,39 +111,37 @@ private void withFrontendDevServer() { .thenReturn(Mode.DEVELOPMENT_FRONTEND_LIVERELOAD); } - private String readGeneratedDefinitions() throws IOException { - return Files.readString( - new File( - FrontendUtils - .getFrontendGeneratedFolder(frontendFolder), - FrontendUtils.JS_DEFINITIONS_FILE_NAME).toPath(), - StandardCharsets.UTF_8); + private File generatedFile() { + return new File( + FrontendUtils.getFrontendGeneratedFolder(frontendFolder), + FrontendUtils.JS_DEFINITIONS_FILE_NAME); } - private void writeGeneratedDefinitions(String content) throws IOException { - File generated = FrontendUtils - .getFrontendGeneratedFolder(frontendFolder); - generated.mkdirs(); - Files.writeString( - new File(generated, FrontendUtils.JS_DEFINITIONS_FILE_NAME) - .toPath(), - content, StandardCharsets.UTF_8); + private String readGeneratedDefinitions() throws IOException { + return Files.readString(generatedFile().toPath(), + StandardCharsets.UTF_8); } /** - * The file as a build writes it for the given interface, which is what a - * browser would be running. + * Writes the file as a build writes it for the given interface, where the + * hotswapper looks for it, which is what a browser would be running. */ - private String generatedFor(Class definition) throws IOException { + private void writeGeneratedDefinitionsFor(Class definition) { Options options = new Options(Mockito.mock(Lookup.class), null, null) .withFrontendDirectory(frontendFolder); TaskGenerateJsDefinitions.updateJsDefinitions(options, List.of(definition)); - String content = readGeneratedDefinitions(); - Files.delete(new File( - FrontendUtils.getFrontendGeneratedFolder(frontendFolder), - FrontendUtils.JS_DEFINITIONS_FILE_NAME).toPath()); - return content; + } + + /** + * Edits the written file, to make it the older version of the declarations + * that a browser would still be running. + */ + private void editGeneratedDefinitions(String declared, String previously) + throws IOException { + Files.writeString(generatedFile().toPath(), + readGeneratedDefinitions().replace(declared, previously), + StandardCharsets.UTF_8); } private void classesChanged(Class... classes) { @@ -152,8 +150,8 @@ private void classesChanged(Class... classes) { } @Test - void fileCarriesTheDeclarations_nothingReported() throws IOException { - writeGeneratedDefinitions(generatedFor(GreeterJs.class)); + void fileCarriesTheDeclarations_nothingReported() { + writeGeneratedDefinitionsFor(GreeterJs.class); classesChanged(GreeterJs.class); @@ -173,8 +171,9 @@ void fileDoesNotCarryTheDeclarations_reported() { @Test void frontendDevServerRunning_appliedInsteadOfReported() throws IOException { - writeGeneratedDefinitions(generatedFor(GreeterJs.class) - .replace("window.alert($0); this.focus()", "window.alert($0)")); + writeGeneratedDefinitionsFor(GreeterJs.class); + editGeneratedDefinitions("window.alert($0); this.focus()", + "window.alert($0)"); withFrontendDevServer(); classesChanged(GreeterJs.class); @@ -189,15 +188,11 @@ void frontendDevServerRunning_appliedInsteadOfReported() } @Test - void frontendDevServerRunningAndFileUpToDate_fileLeftAlone() - throws IOException { - writeGeneratedDefinitions(generatedFor(GreeterJs.class)); - File generated = new File( - FrontendUtils.getFrontendGeneratedFolder(frontendFolder), - FrontendUtils.JS_DEFINITIONS_FILE_NAME); + void frontendDevServerRunningAndFileUpToDate_fileLeftAlone() { + writeGeneratedDefinitionsFor(GreeterJs.class); // A moment in the past, so that a write of the same content shows - generated.setLastModified(System.currentTimeMillis() - 60_000); - long untouched = generated.lastModified(); + generatedFile().setLastModified(System.currentTimeMillis() - 60_000); + long untouched = generatedFile().lastModified(); withFrontendDevServer(); classesChanged(GreeterJs.class); @@ -205,7 +200,7 @@ void frontendDevServerRunningAndFileUpToDate_fileLeftAlone() assertTrue(hotswapper.reported.isEmpty(), "the browser is running what the interface declares: " + hotswapper.reported); - assertEquals(untouched, generated.lastModified(), + assertEquals(untouched, generatedFile().lastModified(), "a redefinition that changes no JavaScript should leave the file alone, or the dev server replaces the module in every browser for nothing"); } @@ -213,8 +208,7 @@ void frontendDevServerRunningAndFileUpToDate_fileLeftAlone() void frontendDevServerRunningButNothingCanBeWritten_reported() { // A directory where the file belongs: the change cannot be applied, so // it is reported rather than passing as applied - new File(FrontendUtils.getFrontendGeneratedFolder(frontendFolder), - FrontendUtils.JS_DEFINITIONS_FILE_NAME).mkdirs(); + generatedFile().mkdirs(); withFrontendDevServer(); classesChanged(GreeterJs.class); From 3f0766c6ee52854581d1163aa28db4da2263ee11 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 16:20:54 +0000 Subject: [PATCH 41/57] refactor: one way to build a method identifier, one place for the lookup The call had both an instance getter and the static that it called, and the wire is the only caller of the getter, so the static is what is left - the format of the key the bundle registers a function under lives in one place, for the build that writes it and for the server that names it. The identifier of the interface was the class name behind a getter, which the wire now takes from the call itself. Finding a method by name and argument count is not what the reflection utilities do - they look one up by parameter types, which a caller that has the arguments of a call does not have - so it moves to ReflectTools next to them rather than staying a private search here. --- .../vaadin/flow/internal/ReflectTools.java | 26 +++++++++++++++ .../main/java/com/vaadin/flow/js/JsCall.java | 33 ++++--------------- .../flow/server/communication/UidlWriter.java | 4 +-- .../flow/internal/ReflectToolsTest.java | 30 +++++++++++++++++ .../java/com/vaadin/flow/js/JsCallTest.java | 9 ----- 5 files changed, 64 insertions(+), 38 deletions(-) diff --git a/flow-server/src/main/java/com/vaadin/flow/internal/ReflectTools.java b/flow-server/src/main/java/com/vaadin/flow/internal/ReflectTools.java index e3c69ee4d4a..a80b5df17b4 100644 --- a/flow-server/src/main/java/com/vaadin/flow/internal/ReflectTools.java +++ b/flow-server/src/main/java/com/vaadin/flow/internal/ReflectTools.java @@ -166,6 +166,32 @@ public static Optional findDeclaredMethod(Class cls, return Optional.empty(); } + /** + * Locates the public methods of the given type that have the given name and + * take the given number of parameters, which is the lookup available to a + * caller that has the arguments of a call rather than the parameter types + * of the method - values, whose classes are not the declarations. + *

+ * More than one is returned when the type overloads the name with the same + * number of parameters, which such a caller cannot tell apart. + * + * @param cls + * the type to look the methods up in + * @param methodName + * the name of the methods + * @param parameterCount + * the number of parameters the methods take + * @return the methods with that name and that number of parameters, empty + * if the type has none + */ + public static List getMethodsWithParameterCount(Class cls, + String methodName, int parameterCount) { + return Stream.of(cls.getMethods()) + .filter(method -> method.getName().equals(methodName) + && method.getParameterCount() == parameterCount) + .toList(); + } + /** * Returns the value of the java field. *

diff --git a/flow-server/src/main/java/com/vaadin/flow/js/JsCall.java b/flow-server/src/main/java/com/vaadin/flow/js/JsCall.java index 20ab55f0f1e..23cbb6bc48a 100644 --- a/flow-server/src/main/java/com/vaadin/flow/js/JsCall.java +++ b/flow-server/src/main/java/com/vaadin/flow/js/JsCall.java @@ -19,12 +19,12 @@ 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; import com.vaadin.flow.dom.Element; +import com.vaadin.flow.internal.ReflectTools; /** * A call made through {@link Element#executeJs(Class)}: which definition @@ -73,30 +73,11 @@ public record JsCall(Class definitionType, String methodName, arguments = Collections.unmodifiableList(new ArrayList<>(arguments)); } - /** - * Gets the identifier of the JavaScript definition, which is the key the - * generated bundle registers its functions under. - * - * @return the definition identifier, not null - */ - public String getDefinitionId() { - return definitionType.getName(); - } - - /** - * Gets the identifier of the called method within its definition, 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. + * arguments, which is the key the generated bundle registers the function + * of that method under, within the interface it belongs to. The number of + * arguments is part of it so that overloads stay apart. * * @param methodName * the method name, not null @@ -172,10 +153,8 @@ public Object invokeOn(Object implementation) { * limitation of the prototype rather than of the idea. */ private Method resolveMethod() { - List candidates = Arrays.stream(definitionType.getMethods()) - .filter(method -> method.getName().equals(methodName) - && method.getParameterCount() == arguments.size()) - .toList(); + List candidates = ReflectTools.getMethodsWithParameterCount( + definitionType, methodName, arguments.size()); if (candidates.size() != 1) { throw new IllegalStateException("Expected exactly one method named " + methodName + " with " + arguments.size() 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 cdc2cc5d6ea..8b8735c1283 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 @@ -404,9 +404,9 @@ private static ArrayNode encodeJsCall( ObjectNode target = JacksonUtils.createObjectNode(); target.put(JsonConstants.UIDL_KEY_JS_DEFINITION, - call.getDefinitionId()); + call.definitionType().getName()); target.put(JsonConstants.UIDL_KEY_JS_DEFINITION_METHOD, - call.getMethodId()); + JsCall.methodId(call.methodName(), call.arguments().size())); target.put(JsonConstants.UIDL_KEY_JS_DEFINITION_ARGUMENTS, call.arguments().size()); diff --git a/flow-server/src/test/java/com/vaadin/flow/internal/ReflectToolsTest.java b/flow-server/src/test/java/com/vaadin/flow/internal/ReflectToolsTest.java index ffb372337b3..9f44a787b05 100644 --- a/flow-server/src/test/java/com/vaadin/flow/internal/ReflectToolsTest.java +++ b/flow-server/src/test/java/com/vaadin/flow/internal/ReflectToolsTest.java @@ -617,6 +617,23 @@ void findDeclaredMethod_otherParameterTypes_empty() { "superMethod", Integer.class).isEmpty()); } + @Test + void getMethodsWithParameterCount_nameAndArity_allTheCallerCannotTellApart() { + assertEquals( + List.of(ReflectTools.findMethod(Overloading.class, "once", + String.class)), + ReflectTools.getMethodsWithParameterCount(Overloading.class, + "once", 1), + "a method should be found without knowing the parameter types"); + assertEquals(2, + ReflectTools.getMethodsWithParameterCount(Overloading.class, + "overloaded", 1).size(), + "overloads with the same arity cannot be told apart this way"); + assertTrue(ReflectTools + .getMethodsWithParameterCount(Overloading.class, "once", 3) + .isEmpty()); + } + @Test void findDeclaredMethod_declaredOnObject_empty() { assertTrue(ReflectTools @@ -624,6 +641,19 @@ void findDeclaredMethod_declaredOnObject_empty() { .isEmpty()); } + // S1172: the parameters are what the lookup by arity tells apart + @SuppressWarnings("java:S1172") + public static class Overloading { + public void once(String value) { + } + + public void overloaded(String value) { + } + + public void overloaded(int value) { + } + } + // S1068 and S1144: the members are looked up and used reflectively @SuppressWarnings({ "java:S1068", "java:S1144" }) private static class FieldsAndMethodsSuperclass { diff --git a/flow-server/src/test/java/com/vaadin/flow/js/JsCallTest.java b/flow-server/src/test/java/com/vaadin/flow/js/JsCallTest.java index d8e699e8381..8368f3726f1 100644 --- a/flow-server/src/test/java/com/vaadin/flow/js/JsCallTest.java +++ b/flow-server/src/test/java/com/vaadin/flow/js/JsCallTest.java @@ -69,14 +69,6 @@ private static JsCall call(String methodName, Object... arguments) { Arrays.asList(arguments)); } - @Test - void identifiers_nameTheInterfaceAndTheMethodWithItsArity() { - JsCall call = call("showGreeting", "Hello"); - - assertEquals(GreeterJs.class.getName(), call.getDefinitionId()); - assertEquals("showGreeting/1", call.getMethodId()); - } - @Test void getExpression_methodWithoutDeclaredJavaScript_throws() { IllegalStateException exception = assertThrows( @@ -114,7 +106,6 @@ void nullArgument_keptAndPassedToTheImplementation() { Greeter greeter = new Greeter(); assertEquals(Collections.singletonList(null), call.arguments()); - assertEquals("showGreeting/1", call.getMethodId()); call.invokeOn(greeter); From 01d9f161d51b3f0684a8f973bc361ce3bac51815 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 19:04:46 +0000 Subject: [PATCH 42/57] refactor: let a definition declare only JavaScript, and check it elsewhere Every method of a JavaScript definition declares the JavaScript it runs now. A default or static method is refused rather than run in Java, so an interface says one thing: what the browser does. Composing calls is for the class that makes them, and the rules about a public interface and an annotated default method go with the support for them. Checking an interface and handing out its implementation has nothing to do with an element, so it moves to JsDefinitionProxy in the package the rest of this lives in, with what runs the call passed in. The element keeps what is its own: scheduling the call on itself. The cases about the interface move to the new class as well, leaving ElementTest with what the element does with a call. --- .../frontend/TaskGenerateJsDefinitions.java | 5 +- .../java/com/vaadin/flow/dom/Element.java | 135 +-------------- .../com/vaadin/flow/js/JsDefinitionProxy.java | 161 ++++++++++++++++++ .../java/com/vaadin/flow/dom/ElementTest.java | 149 +--------------- .../vaadin/flow/js/JsDefinitionProxyTest.java | 151 ++++++++++++++++ .../testutil/ClassesSerializableTest.java | 1 + 6 files changed, 326 insertions(+), 276 deletions(-) create mode 100644 flow-server/src/main/java/com/vaadin/flow/js/JsDefinitionProxy.java create mode 100644 flow-server/src/test/java/com/vaadin/flow/js/JsDefinitionProxyTest.java diff --git a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitions.java b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitions.java index a9b5ce5c515..4d0ddc07d91 100644 --- a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitions.java +++ b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitions.java @@ -283,10 +283,7 @@ static List renderDefinitionLines(Class definition) { List lines = new ArrayList<>(); List methods = new ArrayList<>(); for (Method method : definition.getMethods()) { - // 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()) { + if (method.isAnnotationPresent(JsExpression.class)) { 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 a0fe9901018..afc9208b9f6 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,11 +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.Modifier; -import java.lang.reflect.Proxy; -import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.List; @@ -71,6 +66,7 @@ import com.vaadin.flow.internal.nodefeature.VirtualChildrenList; import com.vaadin.flow.js.JsCall; import com.vaadin.flow.js.JsDefinition; +import com.vaadin.flow.js.JsDefinitionProxy; import com.vaadin.flow.js.JsExpression; import com.vaadin.flow.server.AbstractStreamResource; import com.vaadin.flow.server.Command; @@ -1988,12 +1984,11 @@ public PendingJavaScriptResult executeJs(String expression, * 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}. 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. + * Every method of the interface declares JavaScript and returns either + * void or {@link PendingJavaScriptResult}. One that is + * implemented in Java instead - a default or a + * static method - is not what such an interface is for, so the + * interface is refused rather than partly run in the browser. *

* The interface is checked when the implementation is handed out, so one * that can not work says so here rather than at the first call. @@ -2009,124 +2004,8 @@ public PendingJavaScriptResult executeJs(String expression, * {@link JsDefinition}, or has a method that can not be * answered */ - @SuppressWarnings("unchecked") public T executeJs(Class definitionType) { - Objects.requireNonNull(definitionType, - "Definition type cannot be null"); - if (!definitionType.isInterface()) { - throw new IllegalArgumentException( - definitionType.getName() + " is not an interface"); - } - if (!definitionType.isAnnotationPresent(JsDefinition.class)) { - throw new IllegalArgumentException(definitionType.getName() - + " is not annotated with @JsDefinition, so the build does not" - + " collect its JavaScript into the bundle"); - } - checkDefinitionMethods(definitionType); - return (T) Proxy.newProxyInstance(definitionType.getClassLoader(), - new Class[] { definitionType }, - new JsDefinitionHandler(this, definitionType)); - } - - /** - * Checks the methods of a JavaScript definition: each one that has to be - * answered 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 checkDefinitionMethods(Class definitionType) { - List undeclared = new ArrayList<>(); - List unanswerable = new ArrayList<>(); - List inJava = new ArrayList<>(); - for (Method method : definitionType.getMethods()) { - 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)) { - 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(definitionType.getName() - + " declares no JavaScript to run for " - + String.join(", ", undeclared) - + ". Annotate the methods with @JsExpression, or make them" - + " default methods, without the annotation, if they are" - + " meant to run in Java"); - } - if (!unanswerable.isEmpty()) { - throw new IllegalArgumentException(definitionType.getName() - + " has " + String.join(", ", unanswerable) - + " returning something that can not be answered with." - + " A method returns void or PendingJavaScriptResult"); - } - if (!inJava.isEmpty() - && !Modifier.isPublic(definitionType.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(definitionType.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"); - } - } - - /** - * Turns a call on a JavaScript definition into a scheduled invocation that - * carries the call. - */ - private record JsDefinitionHandler(Element element, Class definitionType) - 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); - } - if (method.isDefault()) { - // Runs in Java, and what it calls of the interface comes back - // here - try { - return InvocationHandler.invokeDefault(proxy, method, args); - } catch (IllegalAccessException e) { - throw new IllegalStateException("Cannot run " - + method.getName() + " of " - + definitionType.getName() - + " in Java. Make the interface public, or declare" - + " the JavaScript of the method with" - + " @JsExpression", e); - } - } - boolean returnsResult = method.getReturnType() - .isAssignableFrom(PendingJavaScriptResult.class); - List arguments = args == null ? List.of() - : Arrays.asList(args); - PendingJavaScriptResult result = element.scheduleJsCall( - new JsCall(definitionType, method.getName(), arguments)); - return returnsResult ? result : null; - } + return JsDefinitionProxy.create(definitionType, this::scheduleJsCall); } private PendingJavaScriptResult scheduleExecuteJs(String expression, diff --git a/flow-server/src/main/java/com/vaadin/flow/js/JsDefinitionProxy.java b/flow-server/src/main/java/com/vaadin/flow/js/JsDefinitionProxy.java new file mode 100644 index 00000000000..4bd44240cf7 --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/js/JsDefinitionProxy.java @@ -0,0 +1,161 @@ +/* + * 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.Modifier; +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + +import com.vaadin.flow.component.page.PendingJavaScriptResult; +import com.vaadin.flow.dom.Element; +import com.vaadin.flow.function.SerializableFunction; + +/** + * Hands out implementations of {@link JsDefinition} interfaces, which turn a + * call of a declared method into a {@link JsCall} and give it to whoever runs + * it. + *

+ * The interface is checked here rather than when a method is called, so one + * that can not work says so when the implementation is handed out. + *

+ * For internal use only. Call {@link Element#executeJs(Class)}, which runs the + * call on the element the implementation was obtained from. + */ +public final class JsDefinitionProxy { + + private JsDefinitionProxy() { + // Only static members + } + + /** + * Creates an implementation of the given JavaScript definition, which hands + * every call of it to the given function and answers with what the function + * answers, for a method that declares a result. + * + * @param + * the JavaScript definition type + * @param definitionType + * the JavaScript definition, not null + * @param runner + * what runs a call of the interface, not null + * @return an implementation of the interface, not null + * @throws IllegalArgumentException + * if the type is not an interface, is not annotated with + * {@link JsDefinition}, or has a method that can not be + * answered + */ + @SuppressWarnings("unchecked") + public static T create(Class definitionType, + SerializableFunction runner) { + Objects.requireNonNull(definitionType, + "Definition type cannot be null"); + Objects.requireNonNull(runner, "Runner cannot be null"); + if (!definitionType.isInterface()) { + throw new IllegalArgumentException( + definitionType.getName() + " is not an interface"); + } + if (!definitionType.isAnnotationPresent(JsDefinition.class)) { + throw new IllegalArgumentException(definitionType.getName() + + " is not annotated with @JsDefinition, so the build does not" + + " collect its JavaScript into the bundle"); + } + checkMethods(definitionType); + return (T) Proxy.newProxyInstance(definitionType.getClassLoader(), + new Class[] { definitionType }, + new JsDefinitionHandler(runner, definitionType)); + } + + /** + * Checks the methods of a JavaScript definition: every one of them declares + * the JavaScript it runs, and returns either nothing or the pending result + * of running it. An interface that declares JavaScript declares nothing + * else, so a method that is implemented in Java - a default or a static one + * - is refused rather than left aside. + */ + private static void checkMethods(Class definitionType) { + List undeclared = new ArrayList<>(); + List unanswerable = new ArrayList<>(); + List inJava = new ArrayList<>(); + for (Method method : definitionType.getMethods()) { + if (method.isDefault() + || Modifier.isStatic(method.getModifiers())) { + inJava.add(method.getName()); + 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(definitionType.getName() + + " declares no JavaScript to run for " + + String.join(", ", undeclared) + + ". Annotate every method with @JsExpression"); + } + if (!unanswerable.isEmpty()) { + throw new IllegalArgumentException(definitionType.getName() + + " has " + String.join(", ", unanswerable) + + " returning something that can not be answered with." + + " A method returns void or PendingJavaScriptResult"); + } + if (!inJava.isEmpty()) { + throw new IllegalArgumentException(definitionType.getName() + + " has " + String.join(", ", inJava) + + " implemented in Java, which is not what an interface" + + " that declares JavaScript is for. Annotate every method" + + " with @JsExpression, and compose the calls in the class" + + " that makes them"); + } + } + + /** + * Turns a call of a JavaScript definition into a {@link JsCall} and runs it + * through the function it was created with. + */ + private record JsDefinitionHandler( + SerializableFunction runner, + Class definitionType) + 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); + } + boolean returnsResult = method.getReturnType() + .isAssignableFrom(PendingJavaScriptResult.class); + List arguments = args == null ? List.of() + : Arrays.asList(args); + PendingJavaScriptResult result = runner.apply( + new JsCall(definitionType, method.getName(), arguments)); + return returnsResult ? result : null; + } + } +} 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 fcfb610c97f..4e47e44c20a 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 @@ -2687,110 +2687,21 @@ void executeJsWithDefinition_schedulesTheDeclaredExpressionAndCarriesTheCall() { invocation.getJsCall()); } - @Test - void executeJsWithDefinition_interfaceWithoutAnnotation_throws() { - Element element = ElementFactory.createDiv(); - - assertThrows(IllegalArgumentException.class, - () -> element.executeJs(Serializable.class), - "an interface the build does not collect should be rejected"); - } - - @Test - void executeJsWithDefinition_notAnInterface_throws() { - Element element = ElementFactory.createDiv(); - - assertThrows(IllegalArgumentException.class, - () -> element.executeJs(ElementTest.class), - "only an interface can declare JavaScript methods"); - } - @Test void executeJsWithDefinition_methodReturningAResult_schedulesAndReturnsIt() { UI ui = new MockUI(); Element element = ElementFactory.createDiv(); ui.getElement().appendChild(element); - ResultJs resultJs = element.executeJs(ResultJs.class); - assertNotNull(resultJs.toString(), - "the implementation should answer the methods of Object"); - - PendingJavaScriptResult result = resultJs.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 executeJsWithDefinition_methodWithAnotherReturnType_throws() { - Element element = ElementFactory.createDiv(); - - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> element.executeJs(UnsupportedJs.class), - "a method that can not be answered should be refused when the implementation is handed out"); - - assertTrue(exception.getMessage().contains("readValue"), - "the message should name the method: " - + exception.getMessage()); - } - - @Test - void executeJsWithDefinition_methodWithoutDeclaredJavaScript_throws() { - Element element = ElementFactory.createDiv(); - - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> element.executeJs(UndeclaredJs.class)); - - assertTrue(exception.getMessage().contains("undeclared"), - "the message should name the method that declares nothing: " - + exception.getMessage()); - } - - @Test - void executeJsWithDefinition_defaultMethodOnANonPublicInterface_throws() { - Element element = ElementFactory.createDiv(); - - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> element.executeJs(NotPublicJs.class)); - - assertTrue(exception.getMessage().contains("public"), - "the message should say what stops the method from running: " - + exception.getMessage()); - } - - @Test - void executeJsWithDefinition_defaultMethodDeclaringJavaScript_throws() { - Element element = ElementFactory.createDiv(); - - assertThrows(IllegalArgumentException.class, - () -> element.executeJs(ContradictoryJs.class), - "a method can run in Java or in the browser, not both"); - } - - @Test - void executeJsWithDefinition_defaultMethod_runsInJavaAndSchedulesWhatItCalls() { - UI ui = new MockUI(); - Element element = ElementFactory.createDiv(); - ui.getElement().appendChild(element); - - ComposingJs composingJs = element.executeJs(ComposingJs.class); - composingJs.twice("foo"); + PendingJavaScriptResult result = element.executeJs(ResultJs.class) + .readValue(); ui.getInternals().getStateTree().runExecutionsBeforeClientResponse(); 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 JsCall(ComposingJs.class, "method", List.of("foo")), - pendingJs.get(0).getInvocation().getJsCall()); - assertEquals("composing", composingJs.describe(), - "a default method answers in Java, so it is not bound by what a declared one may return"); + assertEquals(1, pendingJs.size()); + assertSame(pendingJs.get(0), result, + "the result of the call should be the invocation the element scheduled"); } @JsDefinition @@ -2799,56 +2710,6 @@ interface ResultJs extends Serializable { PendingJavaScriptResult readValue(); } - @JsDefinition - interface UnsupportedJs extends Serializable { - @JsExpression("return this.value;") - String readValue(); - } - - @JsDefinition - interface UndeclaredJs extends Serializable { - void undeclared(); - } - - @JsDefinition - public interface ComposingJs extends Serializable { - @JsExpression("this.method($0)") - void method(String value); - - default void twice(String value) { - method(value); - method(value); - } - - default String describe() { - return "composing"; - } - - // Declares no JavaScript and is not answered by the implementation, - // so handing one out has to leave it alone - static String name() { - return "ComposingJs"; - } - } - - @JsDefinition - interface NotPublicJs extends Serializable { - @JsExpression("this.method()") - void method(); - - default void twice() { - method(); - method(); - } - } - - @JsDefinition - interface ContradictoryJs extends Serializable { - @JsExpression("this.method()") - default void method() { - } - } - @JsDefinition interface TestJs extends Serializable { @JsExpression("this.method($0)") diff --git a/flow-server/src/test/java/com/vaadin/flow/js/JsDefinitionProxyTest.java b/flow-server/src/test/java/com/vaadin/flow/js/JsDefinitionProxyTest.java new file mode 100644 index 00000000000..524d502e1a3 --- /dev/null +++ b/flow-server/src/test/java/com/vaadin/flow/js/JsDefinitionProxyTest.java @@ -0,0 +1,151 @@ +/* + * 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.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import com.vaadin.flow.component.page.PendingJavaScriptResult; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class JsDefinitionProxyTest { + + @JsDefinition + interface GreeterJs extends Serializable { + @JsExpression("window.alert($0)") + void showGreeting(String greeting); + + @JsExpression("return this.value;") + PendingJavaScriptResult readValue(); + } + + @JsDefinition + interface UndeclaredJs extends Serializable { + void undeclared(); + } + + @JsDefinition + interface UnsupportedJs extends Serializable { + @JsExpression("return this.value;") + String readValue(); + } + + @JsDefinition + interface ComposingJs extends Serializable { + @JsExpression("this.method()") + void method(); + + default void twice() { + method(); + method(); + } + } + + @JsDefinition + interface HelpingJs extends Serializable { + @JsExpression("this.method()") + void method(); + + static String name() { + return "HelpingJs"; + } + } + + private final List calls = new ArrayList<>(); + + private final PendingJavaScriptResult result = Mockito + .mock(PendingJavaScriptResult.class); + + private T proxy(Class definitionType) { + return JsDefinitionProxy.create(definitionType, call -> { + calls.add(call); + return result; + }); + } + + @Test + void create_callOfADeclaredMethod_runsItAsACall() { + GreeterJs greeter = proxy(GreeterJs.class); + + greeter.showGreeting("Hello"); + + assertEquals(List.of( + new JsCall(GreeterJs.class, "showGreeting", List.of("Hello"))), + calls); + assertNotNull(greeter.toString(), + "the implementation should answer the methods of Object"); + } + + @Test + void create_methodDeclaringAResult_answersWithWhatRanIt() { + assertEquals(result, proxy(GreeterJs.class).readValue(), + "a method that declares a result answers with the pending one"); + } + + @Test + void create_notAnInterface_throws() { + assertThrows(IllegalArgumentException.class, + () -> proxy(JsDefinitionProxyTest.class), + "only an interface can declare JavaScript methods"); + } + + @Test + void create_interfaceWithoutAnnotation_throws() { + assertThrows(IllegalArgumentException.class, + () -> proxy(Serializable.class), + "an interface the build does not collect should be refused"); + } + + @Test + void create_methodWithoutDeclaredJavaScript_throws() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> proxy(UndeclaredJs.class)); + + assertTrue(exception.getMessage().contains("undeclared"), + "the message should name the method that declares nothing: " + + exception.getMessage()); + } + + @Test + void create_methodWithAnotherReturnType_throws() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> proxy(UnsupportedJs.class)); + + assertTrue(exception.getMessage().contains("readValue"), + "the message should name the method: " + + exception.getMessage()); + } + + @Test + void create_methodImplementedInJava_throws() { + // A default method and a static one are both Java on an interface that + // is about JavaScript, so neither is quietly left aside + assertTrue(assertThrows(IllegalArgumentException.class, + () -> proxy(ComposingJs.class)).getMessage().contains("twice")); + assertTrue(assertThrows(IllegalArgumentException.class, + () -> proxy(HelpingJs.class)).getMessage().contains("name")); + } +} diff --git a/flow-test-generic/src/main/java/com/vaadin/flow/testutil/ClassesSerializableTest.java b/flow-test-generic/src/main/java/com/vaadin/flow/testutil/ClassesSerializableTest.java index a38af3d0102..35e5fd22fa7 100644 --- a/flow-test-generic/src/main/java/com/vaadin/flow/testutil/ClassesSerializableTest.java +++ b/flow-test-generic/src/main/java/com/vaadin/flow/testutil/ClassesSerializableTest.java @@ -268,6 +268,7 @@ protected Stream getExcludedPatterns() { // Static Utilities "com\\.vaadin\\.flow\\.component\\.wakelock\\.WakeLock", + "com\\.vaadin\\.flow\\.js\\.JsDefinitionProxy", // Flow client classes "com\\.vaadin\\.client\\..*", From b64ee9597621d16de103a653a2d4f01629d6b708 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 19:36:28 +0000 Subject: [PATCH 43/57] docs: say what the interface version of executeJs does, the way the other one does The first sentence explained what the method answers with rather than what it runs, so the two overloads read as different things. It now opens the same way the expression version does, and says how running declared JavaScript is the same - async, this element, $0, deferred while detached - before what differs. --- .../java/com/vaadin/flow/dom/Element.java | 39 ++++++++++--------- 1 file changed, 21 insertions(+), 18 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 afc9208b9f6..7fe256e19cc 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 @@ -1949,15 +1949,14 @@ public PendingJavaScriptResult executeJs(String expression, } /** - * 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. + * Asynchronously runs the JavaScript that the given interface declares in + * the browser in the context of this element, through an implementation of + * the interface that this method answers with: calling a method of the + * implementation runs the JavaScript that the method declares, with the + * arguments of the call as its parameters. *

- * The version that takes an interface rather than an expression: the - * interface is annotated with {@link JsDefinition} and each of its methods - * 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: + * The interface is annotated with {@link JsDefinition}, and each of its + * methods declares the JavaScript it runs with {@link JsExpression}: * *

      * @JsDefinition
@@ -1969,16 +1968,20 @@ public PendingJavaScriptResult executeJs(String expression,
      * element.executeJs(GreeterJs.class).showGreeting("Hello");
      * 
* - * Unlike {@link #executeJs(String, Object...)}, nothing about the - * JavaScript is decided at the call site: the build collects the - * declarations of every JavaScript definition 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. 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 declared JavaScript runs the way an expression given to + * {@link #executeJs(String, Object...)} does: in an async + * JavaScript method, with this element available as this and + * the arguments of the call as $0, $1, and so on, + * after pending DOM updates, and deferred while the element is not attached + * or not visible. A method that returns {@link PendingJavaScriptResult} can + * be used to retrieve the return value the same way. + *

+ * What differs is that nothing about the JavaScript is decided at the call + * site: the build collects the declarations of every JavaScript definition + * 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 JsCall}, so a * driver of the client side that can not run JavaScript can recognize it, From ff27ef9d244fb2e24ae9d424fc29c3abd60fa822 Mon Sep 17 00:00:00 2001 From: "totally-not-ai[bot]" <290682512+totally-not-ai[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 05:25:16 +0000 Subject: [PATCH 44/57] feat: send a hash of the JavaScript rather than the Java that declared it A browser was told which interface and which method a call came from, which is the inside of an application to anyone reading the traffic, and which tied the wire to names that Java is free to change. The target of an invocation now carries the identifier of the function to run, which is a hash of the declared JavaScript and the number of arguments it takes, and the generated file registers each function under the same identifier. Renaming an interface or a method changes nothing the browser has; editing what a method declares changes the function. Outside production mode the target also carries what a developer wrote - the interface, the method and the argument count - which the client only prints, so a message about a call is still readable while a production browser is told nothing about the Java. Two methods that declare the same JavaScript for the same number of arguments are now one function in the bundle, and the file is no longer grouped by interface, so an update while the application runs adds what the file does not hold rather than rendering it from names read back out of it. --- .../frontend/TaskGenerateJsDefinitions.java | 159 +++++++----------- .../TaskGenerateJsDefinitionsTest.java | 83 ++++----- .../client/flow/ExecuteJavaScriptProcessor.ts | 38 +++-- .../flow/ExecuteJavaScriptProcessorTests.ts | 34 ++-- .../java/com/vaadin/flow/dom/Element.java | 7 +- .../main/java/com/vaadin/flow/js/JsCall.java | 48 ++++-- .../flow/server/communication/UidlWriter.java | 42 +++-- .../com/vaadin/flow/shared/JsonConstants.java | 30 ++-- .../server/communication/UidlWriterTest.java | 36 +++- .../devserver/devloop/DevLoopRedefiner.java | 18 +- .../hotswap/impl/JsDefinitionHotswapper.java | 4 +- .../devloop/DevLoopRedefinerTest.java | 9 +- 12 files changed, 269 insertions(+), 239 deletions(-) diff --git a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitions.java b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitions.java index 4d0ddc07d91..954268eaa97 100644 --- a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitions.java +++ b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitions.java @@ -23,11 +23,7 @@ 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; @@ -47,17 +43,29 @@ * 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. + * anything from a string: the server sends the identifier of the function to + * run, which is a hash of the JavaScript, and the function is already in the + * bundle. The call survives a content security policy that does not allow + * unsafe-eval, the JavaScript an application can be made to run is + * known when it is built, and what declared it in Java stays there. *

* For internal use only. May be renamed or removed in a future release. */ public class TaskGenerateJsDefinitions extends AbstractTaskClientGenerator { - private static final Pattern DEFINITION_KEY = Pattern.compile( - "window\\.Vaadin\\.Flow\\.jsDefinitions\\[\"([^\"]+)\"\\] ="); + private static final List HEADER = List.of("// @ts-nocheck", + "window.Vaadin = window.Vaadin || {};", + "window.Vaadin.Flow = window.Vaadin.Flow || {};", + "window.Vaadin.Flow.jsDefinitions = window.Vaadin.Flow.jsDefinitions || {};"); + + // 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. + // The export is for https://github.com/vaadin/flow/issues/14184 + private static final List FOOTER = List.of("if (import.meta.hot) {", + " import.meta.hot.accept();", "}", "export {};"); private final Options options; @@ -84,30 +92,11 @@ protected String getFileContent() { * @return the content of the generated file */ 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.jsDefinitions = window.Vaadin.Flow.jsDefinitions || {};"); - + List lines = new ArrayList<>(HEADER); 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, - // 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 {};"); - + lines.addAll(FOOTER); return String.join(System.lineSeparator(), lines); } @@ -135,11 +124,10 @@ public static List> findMissingFromGeneratedFile(Options options, * 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 + * What the file already holds is kept: a function it registers is 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. + * definitions it passes in. A function nothing declares any longer stays in + * the file with nothing calling it, 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 @@ -157,8 +145,7 @@ public static List> findMissingFromGeneratedFile(Options options, public static List> updateJsDefinitions(Options options, Collection> definitions) { String generated = readGeneratedFile(options); - String content = renderFileContent( - mergeWithDefinitionsIn(generated, definitions)); + String content = withMissingEntries(generated, definitions); TaskGenerateJsDefinitions task = new TaskGenerateJsDefinitions(options); try { @@ -177,10 +164,10 @@ public static List> updateJsDefinitions(Options options, /** * Whether the given content carries what the definition 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. + * as this class renders it, so the JavaScript of every method and the + * number of arguments it takes 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 definition, String generated) { @@ -197,27 +184,29 @@ private static boolean isInGeneratedFile(Class definition, } /** - * The given interfaces, plus the ones the content registers that are not - * among them and can still be loaded. + * The given content with the entries of the given definitions that it does + * not hold yet put in front of what closes the file, or the whole file + * rendered when there is no content to add to. */ - private static Collection> mergeWithDefinitionsIn(String generated, + private static String withMissingEntries(String generated, Collection> definitions) { - Map> byName = new LinkedHashMap<>(); - definitions.forEach( - definition -> byName.put(definition.getName(), definition)); - ClassLoader classLoader = definitions.iterator().next() - .getClassLoader(); - for (String name : readDefinitionNames(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 definition {}", name, e); - } + List missing = definitions.stream() + .sorted(Comparator.comparing(Class::getName)) + .filter(definition -> !isInGeneratedFile(definition, generated)) + .flatMap(definition -> renderDefinitionLines(definition) + .stream()) + .toList(); + String footer = String.join(System.lineSeparator(), FOOTER); + if (generated == null || !generated.contains(footer)) { + // Nothing to add to, or something else than this class wrote it + return renderFileContent(definitions); } - return byName.values(); + if (missing.isEmpty()) { + return generated; + } + return generated.replace(footer, + String.join(System.lineSeparator(), missing) + + System.lineSeparator() + footer); } private static String readGeneratedFile(Options options) { @@ -239,36 +228,12 @@ private static Logger getLogger() { return LoggerFactory.getLogger(TaskGenerateJsDefinitions.class); } - /** - * Reads back the names of the JavaScript definitions a generated file - * registers, which is what a browser that has the file can run. Reading the - * format back here keeps it next to {@link #renderDefinitionLines(Class)}, - * which writes it. - * - * @param fileContent - * the content of a generated file, or null - * @return the interface names the file registers, in the order it registers - * them - */ - private static List readDefinitionNames(String fileContent) { - List names = new ArrayList<>(); - if (fileContent == null) { - return names; - } - Matcher matcher = DEFINITION_KEY.matcher(fileContent); - while (matcher.find()) { - String name = matcher.group(1); - if (!names.contains(name)) { - names.add(name); - } - } - return names; - } - /** * Renders what one JavaScript definition 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. + * one function per method that declares JavaScript, registered under the + * identifier of that function, which is what the server sends. The name of + * the interface and of the method are not in it, so a browser is not told + * what declared the JavaScript it runs. *

* Package private: whether a file carries what an interface declares is * answered by {@link #findMissingFromGeneratedFile(Options, Collection)}, @@ -290,11 +255,9 @@ static List renderDefinitionLines(Class definition) { if (methods.isEmpty()) { return lines; } - methods.sort(Comparator.comparing(TaskGenerateJsDefinitions::methodId)); + methods.sort( + Comparator.comparing(TaskGenerateJsDefinitions::functionId)); - lines.add(String.format( - "window.Vaadin.Flow.jsDefinitions[%s] = Object.assign(window.Vaadin.Flow.jsDefinitions[%s] || {}, {", - quote(definition.getName()), quote(definition.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 @@ -304,17 +267,19 @@ static List renderDefinitionLines(Class definition) { .mapToObj(index -> "$" + index) .reduce((first, second) -> first + ", " + second) .orElse(""); - lines.add(String.format(" %s: async function (%s) {", - quote(methodId(method)), parameters)); + lines.add(String.format( + "window.Vaadin.Flow.jsDefinitions[%s] = async function (%s) {", + quote(functionId(method)), parameters)); lines.add(method.getAnnotation(JsExpression.class).value()); - lines.add(" },"); + lines.add("};"); } - lines.add("});"); return lines; } - private static String methodId(Method method) { - return JsCall.methodId(method.getName(), method.getParameterCount()); + private static String functionId(Method method) { + return JsCall.functionId( + method.getAnnotation(JsExpression.class).value(), + method.getParameterCount()); } private static String quote(String value) { diff --git a/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitionsTest.java b/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitionsTest.java index 07ca3e63409..fadaa5b294e 100644 --- a/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitionsTest.java +++ b/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitionsTest.java @@ -29,6 +29,7 @@ import com.vaadin.flow.di.Lookup; 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.server.frontend.scanner.ClassFinder.DefaultClassFinder; @@ -43,9 +44,11 @@ class TaskGenerateJsDefinitionsTest { + private static final String GREETING_EXPRESSION = "window.alert({ text: $0, kind: 'greeting' })"; + @JsDefinition public interface GreeterJs extends Serializable { - @JsExpression("window.alert({ text: $0, kind: 'greeting' })") + @JsExpression(GREETING_EXPRESSION) void showGreeting(String greeting); @JsExpression("window.alert('Hello')") @@ -89,55 +92,44 @@ void generatesAFunctionPerDeclaredExpression() assertTrue( content.contains("window.Vaadin.Flow.jsDefinitions[\"" - + GreeterJs.class.getName() + "\"]"), - "the definition should be registered under its class name: " + + JsCall.functionId(GREETING_EXPRESSION, 1) + + "\"] = async function ($0) {"), + "a function should be registered under the identifier of the JavaScript it runs: " + content); - assertTrue( - content.contains("\"showGreeting/1\": async function ($0) {"), - "an overload should be keyed by name and argument count: " + assertTrue(content.contains(GREETING_EXPRESSION), + "the declared expression should be the body of the function, as it was written: " + content); assertTrue( - content.contains( - "window.alert({ text: $0, kind: 'greeting' })"), - "the declared expression should be the body of the function, as it was written: " + content.contains("window.Vaadin.Flow.jsDefinitions[\"" + + JsCall.functionId("window.alert('Hello')", 0) + + "\"] = async function () {"), + "the overload that takes no arguments is another function: " + content); - assertTrue(content.contains("\"showGreeting/0\": async function () {"), - "the no-argument overload should be generated too: " + content); } @Test - void definitionWithoutDeclaredJavaScript_isNotRegistered() - throws ExecutionFailedException { + void generatedFile_namesNothingOfTheJava() throws ExecutionFailedException { task.execute(); String content = task.getFileContent(); - assertFalse(content.contains(NothingJs.class.getName()), - "an interface that declares no JavaScript has nothing to register: " + assertFalse(content.contains(GreeterJs.class.getName()), + "a browser that loads the file should not be told what declared the JavaScript: " + content); + assertFalse(content.contains("showGreeting"), + "and not what the methods are called either: " + content); } @Test - void updateJsDefinitions_dropsADefinitionTheFileNamesAndNothingHas() - throws ExecutionFailedException, IOException { + void definitionWithoutDeclaredJavaScript_isNotRegistered() + throws ExecutionFailedException { 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_DEFINITIONS_FILE_NAME); - Files.writeString(generated.toPath(), - Files.readString(generated.toPath()).replace( - NothingJs.class.getName(), "com.example.GoneJs")); + String content = task.getFileContent(); - List> missing = TaskGenerateJsDefinitions - .updateJsDefinitions(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"); + assertEquals(2, + content.split("window.Vaadin.Flow.jsDefinitions\\[\"", + -1).length - 1, + "only the two methods that declare JavaScript should be registered: " + + content); } @Test @@ -187,15 +179,7 @@ void findMissingFromGeneratedFile_answersForWhatTheFileCarries() List.of(GreeterJs.class)), "another version of the declarations is not the declarations"); - // What renaming or moving the interface leaves behind: the methods and - // the JavaScript are there, under the name of before - Files.writeString(generated.toPath(), carried - .replace(GreeterJs.class.getName(), "com.example.RenamedJs")); - assertEquals(List.of(GreeterJs.class), - TaskGenerateJsDefinitions.findMissingFromGeneratedFile(options, - List.of(GreeterJs.class)), - "a call looks the interface up by name, so the name is part of carrying it"); - + Files.writeString(generated.toPath(), carried); Files.delete(generated.toPath()); assertEquals(List.of(GreeterJs.class), TaskGenerateJsDefinitions.findMissingFromGeneratedFile(options, @@ -218,12 +202,17 @@ void updateJsDefinitions_writesWhatIsAskedForBesideWhatTheFileHolds() assertTrue(missing.isEmpty()); String written = Files.readString(generated.toPath()); - assertTrue(written.contains(CounterJs.class.getName()), + assertTrue( + written.contains( + JsCall.functionId("this.count = ($0 || 0) + 1", 1)), "the interface that was asked for should be in the file: " + written); - assertTrue(written.contains(GreeterJs.class.getName()), - "the interface the file held should still be in it: " - + written); + assertTrue(written.contains(JsCall.functionId(GREETING_EXPRESSION, 1)), + "what the file held should still be in it: " + written); + assertTrue( + written.indexOf("import.meta.hot") > written.indexOf( + JsCall.functionId("this.count = ($0 || 0) + 1", 1)), + "and what was added should be part of the module: " + written); } @Test 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 64a6aede3c8..9da9fc35321 100644 --- a/flow-client/src/main/frontend/internal/client/flow/ExecuteJavaScriptProcessor.ts +++ b/flow-client/src/main/frontend/internal/client/flow/ExecuteJavaScriptProcessor.ts @@ -68,15 +68,19 @@ interface ContextCallbacks { /** * What an invocation of declared JavaScript ends with instead of an expression: - * the definition 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. + * the function 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. + * + * The function is identified by a hash of the JavaScript it runs, so a message + * about a call has nothing to name it by. Outside production mode the server + * sends what a developer wrote as `debug`, which is only ever printed. */ export interface JsDefinitionTarget { - definition: string; - method: string; + function: string; arguments: number; returns?: boolean; + debug?: string; } type JsDefinitionFunction = (this: unknown, ...args: unknown[]) => unknown; @@ -84,17 +88,25 @@ type JsDefinitionFunction = (this: unknown, ...args: unknown[]) => unknown; type ReturnChannel = (value: unknown) => void; /** - * Looks up the function that the build generated for a definition method. The + * Looks up the function that the build generated for declared JavaScript. 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 findDeclaredFunction(definition: string, method: string): JsDefinitionFunction | undefined { +function findDeclaredFunction(functionId: string): JsDefinitionFunction | undefined { const registry = ( window as unknown as { - Vaadin?: { Flow?: { jsDefinitions?: Record> } }; + Vaadin?: { Flow?: { jsDefinitions?: Record } }; } ).Vaadin?.Flow?.jsDefinitions; - return registry?.[definition]?.[method]; + return registry?.[functionId]; +} + +/** + * What to call the target in a message: what a developer wrote when the server + * sent it, and the identifier of the function otherwise. + */ +function nameOf(target: JsDefinitionTarget): string { + return target.debug ?? target.function; } /** @@ -263,7 +275,7 @@ 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) { - const message = `Expected ${expectedCount} parameters for ${target.definition}.${target.method} but the invocation carries ${parameters.length}. Reload the page to pick up the current signature.`; + const message = `Expected ${expectedCount} parameters for ${nameOf(target)} 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 @@ -281,9 +293,9 @@ export class ExecuteJavaScriptProcessor { const onSuccess = target.returns === true ? (parameters[argumentCount + 1] as ReturnChannel) : undefined; const onError = target.returns === true ? (parameters[argumentCount + 2] as ReturnChannel) : undefined; - const fn = findDeclaredFunction(target.definition, target.method); + const fn = findDeclaredFunction(target.function); if (fn === undefined) { - const message = `No JavaScript in the bundle for ${target.definition}.${target.method}. The JavaScript definition is annotated with @JsDefinition, but the build did not collect it.`; + const message = `No JavaScript in the bundle for ${nameOf(target)}. The JavaScript definition is annotated with @JsDefinition, but the build did not collect it.`; Console.error(message); onError?.(message); return; @@ -300,7 +312,7 @@ export class ExecuteJavaScriptProcessor { } catch (exception) { Console.reportStacktrace(exception); Console.error( - `Exception is thrown while running ${target.definition}.${target.method}. Stacktrace will be dumped separately.` + `Exception is thrown while running ${nameOf(target)}. 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 9e5e82927f0..d1c78a9e22d 100644 --- a/flow-client/src/test/frontend/internal/client/flow/ExecuteJavaScriptProcessorTests.ts +++ b/flow-client/src/test/frontend/internal/client/flow/ExecuteJavaScriptProcessorTests.ts @@ -70,21 +70,22 @@ function registeredNode(registry: TestRegistry, id: number): StateNode { describe('ExecuteJavaScriptProcessor', () => { describe('JavaScript definition calls', () => { - const DEFINITION = 'com.acme.GreeterJs'; + const GREETING = '4e6f2a'; + const VALUE = '9c1b7d'; type DefinitionFunction = (this: unknown, ...args: unknown[]) => unknown; type DefinitionWindow = Window & { - Vaadin?: { Flow?: { jsDefinitions?: Record> } }; + Vaadin?: { Flow?: { jsDefinitions?: Record } }; }; // Registers a function the way the generated bundle does. - function registerDefinition(method: string, fn: DefinitionFunction): void { + function registerDefinition(functionId: string, fn: DefinitionFunction): void { const vaadin = (window as DefinitionWindow).Vaadin ?? {}; (window as DefinitionWindow).Vaadin = vaadin; vaadin.Flow = vaadin.Flow ?? {}; vaadin.Flow.jsDefinitions = vaadin.Flow.jsDefinitions ?? {}; - vaadin.Flow.jsDefinitions[DEFINITION] = { ...vaadin.Flow.jsDefinitions[DEFINITION], [method]: fn }; + vaadin.Flow.jsDefinitions[functionId] = fn; } function processor(): ExecuteJavaScriptProcessor { @@ -97,17 +98,18 @@ describe('ExecuteJavaScriptProcessor', () => { } afterEach(() => { - delete (window as DefinitionWindow).Vaadin?.Flow?.jsDefinitions?.[DEFINITION]; + delete (window as DefinitionWindow).Vaadin?.Flow?.jsDefinitions?.[GREETING]; + delete (window as DefinitionWindow).Vaadin?.Flow?.jsDefinitions?.[VALUE]; }); it('runs the function from the bundle against the element', () => { const calls: Array<{ thisArg: unknown; args: unknown[] }> = []; - registerDefinition('showGreeting/1', function (this: unknown, ...args: unknown[]) { + registerDefinition(GREETING, function (this: unknown, ...args: unknown[]) { calls.push({ thisArg: this, args }); }); const element = { tagName: 'div' }; - processor().execute([['Hello', element, { definition: DEFINITION, method: 'showGreeting/1', arguments: 1 }]]); + processor().execute([['Hello', element, { function: GREETING, arguments: 1 }]]); expect(calls).to.have.lengthOf(1); expect(calls[0].thisArg).to.equal(element); @@ -115,7 +117,7 @@ describe('ExecuteJavaScriptProcessor', () => { }); it('passes the return value to the success channel', async () => { - registerDefinition('readValue/0', () => 'answer'); + registerDefinition(VALUE, () => 'answer'); const resolved: unknown[] = []; const element = { tagName: 'div' }; @@ -124,7 +126,7 @@ describe('ExecuteJavaScriptProcessor', () => { element, (value: unknown) => resolved.push(value), () => {}, - { definition: DEFINITION, method: 'readValue/0', arguments: 0, returns: true } + { function: VALUE, arguments: 0, returns: true } ] ]); // Settled in microtasks: a macrotask wait would also pick up the @@ -137,7 +139,7 @@ describe('ExecuteJavaScriptProcessor', () => { it('does not run a call whose parameters do not match the target', () => { let calls = 0; - registerDefinition('showGreeting/1', () => { + registerDefinition(GREETING, () => { calls += 1; }); @@ -145,14 +147,14 @@ describe('ExecuteJavaScriptProcessor', () => { // invocation and this client disagree about the signature, which is the // same disagreement as an invocation that carries one parameter too // many. - processor().execute([['Hello', { definition: DEFINITION, method: 'showGreeting/1', arguments: 1 }]]); + processor().execute([['Hello', { function: GREETING, arguments: 1 }]]); expect(calls).to.equal(0); }); it('reports a mismatch to the error channel of a call that returns a value', () => { let calls = 0; - registerDefinition('readValue/0', () => { + registerDefinition(VALUE, () => { calls += 1; return 'answer'; }); @@ -164,7 +166,7 @@ describe('ExecuteJavaScriptProcessor', () => { [ element, (error: unknown) => errors.push(error), - { definition: DEFINITION, method: 'readValue/0', arguments: 0, returns: true } + { function: VALUE, arguments: 0, returns: true } ] ]); @@ -183,12 +185,14 @@ describe('ExecuteJavaScriptProcessor', () => { element, () => {}, (error: unknown) => errors.push(error), - { definition: DEFINITION, method: 'missing/0', arguments: 0, returns: true } + { function: 'notinthebundle', arguments: 0, returns: true, debug: 'com.acme.GreeterJs.readValue/0' } ] ]); expect(errors).to.have.lengthOf(1); - expect(String(errors[0])).to.contain(DEFINITION); + // What the server sends outside production mode, so that the message + // says more than a hash does + expect(String(errors[0])).to.contain('com.acme.GreeterJs.readValue/0'); }); }); 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 7fe256e19cc..b955ac0ebc4 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 @@ -1979,9 +1979,10 @@ public PendingJavaScriptResult executeJs(String expression, * What differs is that nothing about the JavaScript is decided at the call * site: the build collects the declarations of every JavaScript definition * 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. + * it up by an identifier of the JavaScript itself. No expression is sent + * and none is compiled in the browser, so the call works under a content + * security policy without unsafe-eval, and what declared the + * JavaScript in Java is not sent to a production browser either. *

* The scheduled invocation carries the call as a {@link JsCall}, so a * driver of the client side that can not run JavaScript can recognize it, diff --git a/flow-server/src/main/java/com/vaadin/flow/js/JsCall.java b/flow-server/src/main/java/com/vaadin/flow/js/JsCall.java index 23cbb6bc48a..4bce72a52f4 100644 --- a/flow-server/src/main/java/com/vaadin/flow/js/JsCall.java +++ b/flow-server/src/main/java/com/vaadin/flow/js/JsCall.java @@ -18,6 +18,7 @@ import java.io.Serializable; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -25,15 +26,17 @@ import com.vaadin.flow.dom.Element; import com.vaadin.flow.internal.ReflectTools; +import com.vaadin.flow.internal.StringUtil; /** * A call made through {@link Element#executeJs(Class)}: which definition * 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 - * 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 + * The call is what is scheduled, and what a driver of the client side that can + * not run JavaScript sees in the pending invocation queue. A browser is sent + * less than this: the identifier of the function to run and the arguments, + * never the JavaScript itself, which it looks up in the bundle, and never the + * interface or the method, which stay on the server. 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: * @@ -74,19 +77,38 @@ public record JsCall(Class definitionType, String methodName, } /** - * Gets the identifier of a method with the given name and number of - * arguments, which is the key the generated bundle registers the function - * of that method under, within the interface it belongs to. The number of - * arguments is part of it so that overloads stay apart. + * Gets the identifier of the function that runs the given JavaScript with + * the given number of arguments, which is the key the generated bundle + * registers that function under and the only thing the client is told about + * a call. + *

+ * A hash of the JavaScript, so that the name of the Java that declared it + * stays on the server. The number of arguments is hashed with it, since it + * is what the parameters of the generated function are made of, and two + * methods that declare the same JavaScript for a different number of + * arguments are two functions. * - * @param methodName - * the method name, not null + * @param expression + * the declared JavaScript, not null * @param argumentCount * the number of arguments - * @return the method identifier, not null + * @return the function identifier, not null + */ + public static String functionId(String expression, int argumentCount) { + return StringUtil.getHash(argumentCount + ":" + expression, + StandardCharsets.UTF_8); + } + + /** + * Describes this call the way a developer wrote it, for a message about it + * that a hash would make unreadable. + * + * @return the interface, the method and the number of arguments, not + * null */ - public static String methodId(String methodName, int argumentCount) { - return methodName + "/" + argumentCount; + public String getDebugInfo() { + return definitionType.getName() + "." + methodName + "/" + + arguments.size(); } /** 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 8b8735c1283..fcdfc35ed4e 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 @@ -181,7 +181,8 @@ public ObjectNode createUidl(UI ui, boolean async, boolean resync) { .dumpPendingJavaScriptInvocations(); if (!executeJavaScriptList.isEmpty()) { response.set(JsonConstants.UIDL_KEY_EXECUTE, - encodeExecuteJavaScriptList(executeJavaScriptList)); + encodeExecuteJavaScriptList(executeJavaScriptList, !service + .getDeploymentConfiguration().isProductionMode())); } if (service.getDeploymentConfiguration().isRequestTiming()) { response.set("timings", createPerformanceData(ui)); @@ -307,9 +308,11 @@ private static InputStream getInlineResourceStream(String url, // non-private for testing purposes static ArrayNode encodeExecuteJavaScriptList( - List executeJavaScriptList) { + List executeJavaScriptList, + boolean withDebugInfo) { return executeJavaScriptList.stream() - .map(UidlWriter::encodeExecuteJavaScript) + .map(invocation -> encodeExecuteJavaScript(invocation, + withDebugInfo)) .collect(JacksonUtils.asArray()); } @@ -330,10 +333,10 @@ private static ReturnChannelRegistration createReturnValueChannel( } private static ArrayNode encodeExecuteJavaScript( - PendingJavaScriptInvocation invocation) { + PendingJavaScriptInvocation invocation, boolean withDebugInfo) { JsCall jsCall = invocation.getInvocation().getJsCall(); if (jsCall != null) { - return encodeJsCall(invocation, jsCall); + return encodeJsCall(invocation, jsCall, withDebugInfo); } List parametersList = invocation.getInvocation() @@ -387,10 +390,13 @@ private static ArrayNode encodeExecuteJavaScript( /** * Encodes a call made through a JavaScript definition as * [argument1, ..., element, successChannel, errorChannel, target], - * where the trailing target object names the JavaScript definition 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. + * where the trailing target object names the function to run instead of + * carrying JavaScript. The function is identified by a hash of the + * JavaScript it runs, so no expression is sent, nothing is compiled in the + * browser, and what declared the JavaScript in Java stays on the server. + *

+ * Outside production mode the target also carries what a developer wrote, + * so that a message about the call can name it. The client only prints it. *

* The target tells the client how to read the parameters: the first * arguments of them are the arguments of the call, the next @@ -398,17 +404,21 @@ private static ArrayNode encodeExecuteJavaScript( * the return value channels when returns is set. */ private static ArrayNode encodeJsCall( - PendingJavaScriptInvocation invocation, JsCall call) { + PendingJavaScriptInvocation invocation, JsCall call, + boolean withDebugInfo) { Stream parameters = invocation.getInvocation().getParameters() .stream(); ObjectNode target = JacksonUtils.createObjectNode(); - target.put(JsonConstants.UIDL_KEY_JS_DEFINITION, - call.definitionType().getName()); - target.put(JsonConstants.UIDL_KEY_JS_DEFINITION_METHOD, - JsCall.methodId(call.methodName(), call.arguments().size())); - target.put(JsonConstants.UIDL_KEY_JS_DEFINITION_ARGUMENTS, + target.put(JsonConstants.UIDL_KEY_JS_FUNCTION, + JsCall.functionId(invocation.getInvocation().getExpression(), + call.arguments().size())); + target.put(JsonConstants.UIDL_KEY_JS_FUNCTION_ARGUMENTS, call.arguments().size()); + if (withDebugInfo) { + target.put(JsonConstants.UIDL_KEY_JS_FUNCTION_DEBUG, + call.getDebugInfo()); + } if (invocation.isSubscribed()) { StateNode owner = invocation.getOwner(); @@ -421,7 +431,7 @@ private static ArrayNode encodeJsCall( parameters = Stream.concat(parameters, Stream.of(successChannel, errorChannel)); - target.put(JsonConstants.UIDL_KEY_JS_DEFINITION_RETURNS, true); + target.put(JsonConstants.UIDL_KEY_JS_FUNCTION_RETURNS, true); } return Stream.concat(parameters.map(JacksonCodec::encodeWithTypeInfo), 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 9729ef8da04..e33f82234d8 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 @@ -166,29 +166,33 @@ public class JsonConstants implements Serializable { public static final String UIDL_KEY_EXECUTE = "execute"; /** - * Key of the JavaScript definition in the target object that ends such an - * invocation in UIDL messages, in place of a JavaScript expression. + * Key of the function to run in the target object that ends an invocation + * of declared JavaScript in UIDL messages, in place of a JavaScript + * expression. The value identifies the JavaScript that the bundle holds, + * and says nothing about the Java that declared it. */ - public static final String UIDL_KEY_JS_DEFINITION = "definition"; + public static final String UIDL_KEY_JS_FUNCTION = "function"; /** - * Key of the invoked method, as its name and argument count, in the target - * object of a JS invocation of declared JavaScript. - */ - public static final String UIDL_KEY_JS_DEFINITION_METHOD = "method"; - - /** - * Key of the number of leading parameters that are the arguments of a JS + * Key of the number of leading parameters that are the arguments of an * invocation of declared JavaScript. The parameter after them is the * element to apply the function to. */ - public static final String UIDL_KEY_JS_DEFINITION_ARGUMENTS = "arguments"; + public static final String UIDL_KEY_JS_FUNCTION_ARGUMENTS = "arguments"; /** - * Key that marks a JS invocation of declared JavaScript whose two last + * Key that marks an invocation of declared JavaScript whose two last * parameters are the channels for its return value. */ - public static final String UIDL_KEY_JS_DEFINITION_RETURNS = "returns"; + public static final String UIDL_KEY_JS_FUNCTION_RETURNS = "returns"; + + /** + * Key of what the client names the function in a message, sent outside + * production mode so that a message about a call can be read. Nothing reads + * it, so a production browser is not told what declared the JavaScript it + * runs. + */ + public static final String UIDL_KEY_JS_FUNCTION_DEBUG = "debug"; /** * Key used to hold the feature id when synchronizing node values. 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 abea9b48135..e539b713547 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 @@ -190,7 +190,7 @@ void testEncodeExecuteJavaScript_npmMode() { .collect(Collectors.toList()); ArrayNode json = UidlWriter - .encodeExecuteJavaScriptList(executeJavaScriptList); + .encodeExecuteJavaScriptList(executeJavaScriptList, false); ArrayNode expectedJson = JacksonUtils.createArray( JacksonUtils.createArray( @@ -213,13 +213,12 @@ void encodeExecuteJavaScript_jsCall_sendsTheTargetInsteadOfTheScript() { JavaScriptInvocation invocation = new JavaScriptInvocation(call, call.getExpression(), "foo", element); - ArrayNode json = UidlWriter.encodeExecuteJavaScriptList( - List.of(new PendingJavaScriptInvocation(element.getNode(), - invocation))); + ArrayNode json = UidlWriter.encodeExecuteJavaScriptList(List.of( + new PendingJavaScriptInvocation(element.getNode(), invocation)), + false); ObjectNode target = JacksonUtils.createObjectNode(); - target.put("definition", TestJs.class.getName()); - target.put("method", "method/1"); + target.put("function", JsCall.functionId("this.method($0)", 1)); target.put("arguments", 1); ArrayNode expectedJson = JacksonUtils.createArray( JacksonUtils.createArray(JacksonUtils.createNode("foo"), @@ -229,6 +228,29 @@ void encodeExecuteJavaScript_jsCall_sendsTheTargetInsteadOfTheScript() { assertTrue(JacksonUtils.jsonEquals(expectedJson, json), "a call of declared JavaScript should carry its target, and no JavaScript: " + json); + assertFalse(json.toString().contains(TestJs.class.getName()), + "a production browser should not be told what declared the JavaScript: " + + json); + assertFalse(json.toString().contains("method"), + "and not what the method is called either: " + json); + } + + @Test + void encodeExecuteJavaScript_jsCallOutsideProductionMode_addsWhatToCallIt() { + Element element = ElementFactory.createDiv(); + + JsCall call = new JsCall(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)), + true); + + assertEquals(TestJs.class.getName() + ".method/1", + ((ObjectNode) ((ArrayNode) json.get(0)).get(2)).get("debug") + .asString(), + "a message about the call should be able to name it: " + json); } @Test @@ -244,7 +266,7 @@ void encodeExecuteJavaScript_subscribedDefinitionCall_addsTheReturnChannels() { }); ArrayNode json = UidlWriter - .encodeExecuteJavaScriptList(List.of(pending)); + .encodeExecuteJavaScriptList(List.of(pending), false); ArrayNode encoded = (ArrayNode) json.get(0); assertEquals(5, encoded.size(), 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 5fb2e7eb798..4a77951ec5a 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 @@ -1378,21 +1378,19 @@ static String frontendDependencies(Class type) { } } // The JavaScript a JavaScript definition 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. + // 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. What identifies a function is what it runs, so that is what + // the fingerprint is made of: renaming a method changes nothing the + // browser has, and editing what it declares changes everything. if (type.isAnnotationPresent(JsDefinition.class)) { for (Method method : type.getMethods()) { JsExpression expression = method .getAnnotation(JsExpression.class); if (expression != null) { - imports.add("jsdefinition:" - + JsCall.methodId(method.getName(), - method.getParameterCount()) - + ":" + expression.value()); + imports.add("jsdefinition:" + JsCall.functionId( + expression.value(), method.getParameterCount())); } } } diff --git a/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/hotswap/impl/JsDefinitionHotswapper.java b/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/hotswap/impl/JsDefinitionHotswapper.java index 7638f39dcdf..bfd4f6080b8 100644 --- a/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/hotswap/impl/JsDefinitionHotswapper.java +++ b/vaadin-dev-server/src/main/java/com/vaadin/base/devserver/hotswap/impl/JsDefinitionHotswapper.java @@ -54,8 +54,8 @@ *

* 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. + * so the declared JavaScript of every method and the number of arguments it + * takes have to match for an interface to pass silently. *

* For internal use only. May be renamed or removed in a future release. */ 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 b3b05881ab3..6c72ecfd754 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,6 +37,7 @@ 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.js.JsCall; import com.vaadin.flow.js.JsDefinition; import com.vaadin.flow.js.JsExpression; import com.vaadin.flow.theme.Theme; @@ -198,9 +199,11 @@ void frontendDependencies_seesTheJavaScriptADefinitionDeclares() { // bundle was built with until a restart regenerates it. String imports = DevLoopRedefiner.frontendDependencies(GreeterJs.class); - assertTrue(imports.contains("jsdefinition:showGreeting/1"), imports); - // An edited expression keeps the same method, so the expression itself - // has to be part of the comparison. + assertTrue(imports.contains( + "jsdefinition:" + JsCall.functionId("window.alert($0)", 1)), + imports); + // What the browser has of a method is the function of what it + // declares, so an edited expression is a different one. assertNotEquals(imports, DevLoopRedefiner.frontendDependencies(EditedGreeterJs.class)); } From 6fee071f7e2b3dba0703c5da881d20e2b9966fbe Mon Sep 17 00:00:00 2001 From: "totally-not-ai[bot]" <290682512+totally-not-ai[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 05:32:30 +0000 Subject: [PATCH 45/57] fix: add the functions a generated file is missing, and never take any out Writing the file again while the application runs rendered it from the definitions the caller passed whenever it could not find what closes the file, which is the redefined classes only - every other function a browser had would have disappeared from it. What the file holds is kept now, and the functions go after it when another version of this class wrote it. The comparison is per function rather than per interface too, so editing one method of an interface no longer writes its other methods into the file a second time. The response a UI writes is what decides whether a browser is told what declared the JavaScript, so that is where it is asserted, for an application running in production mode and in development mode. --- .../frontend/TaskGenerateJsDefinitions.java | 68 ++++++++++++------- .../TaskGenerateJsDefinitionsTest.java | 58 +++++++++++++++- .../server/communication/UidlWriterTest.java | 42 ++++++++++-- 3 files changed, 138 insertions(+), 30 deletions(-) diff --git a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitions.java b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitions.java index 954268eaa97..61bbbb4f173 100644 --- a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitions.java +++ b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitions.java @@ -21,6 +21,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; import java.util.Comparator; import java.util.List; @@ -184,29 +185,43 @@ private static boolean isInGeneratedFile(Class definition, } /** - * The given content with the entries of the given definitions that it does - * not hold yet put in front of what closes the file, or the whole file - * rendered when there is no content to add to. + * The given content with the functions of the given definitions that it + * does not hold yet put in front of what closes the file, or the whole file + * rendered when there is nothing to add to. + *

+ * Only the functions that are not in the content are added, so editing one + * method of an interface does not write the others a second time. Nothing + * the content holds is taken out of it: it is what a browser that has the + * file can run, and this is asked for the definitions that changed rather + * than for everything an application declares. */ private static String withMissingEntries(String generated, Collection> definitions) { + if (generated == null || generated.isBlank()) { + return renderFileContent(definitions); + } List missing = definitions.stream() .sorted(Comparator.comparing(Class::getName)) - .filter(definition -> !isInGeneratedFile(definition, generated)) - .flatMap(definition -> renderDefinitionLines(definition) - .stream()) + .flatMap(definition -> renderFunctions(definition).stream()) + .filter(function -> !generated.contains(function)) + .flatMap(function -> Arrays + .stream(function.split(System.lineSeparator()))) .toList(); - String footer = String.join(System.lineSeparator(), FOOTER); - if (generated == null || !generated.contains(footer)) { - // Nothing to add to, or something else than this class wrote it - return renderFileContent(definitions); - } if (missing.isEmpty()) { return generated; } - return generated.replace(footer, - String.join(System.lineSeparator(), missing) - + System.lineSeparator() + footer); + String separator = System.lineSeparator(); + String footer = String.join(separator, FOOTER); + String added = String.join(separator, missing); + if (generated.contains(footer)) { + return generated.replace(footer, added + separator + footer); + } + // Written by another version of this class: what it holds is what a + // browser has, so the functions go after it rather than instead of it. + // The header only assigns what is not there, so repeating it is what + // makes the content that follows land in the registry. + return generated + separator + String.join(separator, HEADER) + + separator + added + separator + footer; } private static String readGeneratedFile(Options options) { @@ -245,19 +260,27 @@ private static Logger getLogger() { * JavaScript */ static List renderDefinitionLines(Class definition) { - List lines = new ArrayList<>(); + return renderFunctions(definition).stream() + .flatMap(function -> Arrays + .stream(function.split(System.lineSeparator()))) + .toList(); + } + + /** + * What one JavaScript definition contributes, one registered function per + * method that declares JavaScript, each as the lines it is written as. + */ + private static List renderFunctions(Class definition) { List methods = new ArrayList<>(); for (Method method : definition.getMethods()) { if (method.isAnnotationPresent(JsExpression.class)) { methods.add(method); } } - if (methods.isEmpty()) { - return lines; - } methods.sort( Comparator.comparing(TaskGenerateJsDefinitions::functionId)); + List functions = new ArrayList<>(); 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 @@ -267,13 +290,12 @@ static List renderDefinitionLines(Class definition) { .mapToObj(index -> "$" + index) .reduce((first, second) -> first + ", " + second) .orElse(""); - lines.add(String.format( + functions.add(String.join(System.lineSeparator(), String.format( "window.Vaadin.Flow.jsDefinitions[%s] = async function (%s) {", - quote(functionId(method)), parameters)); - lines.add(method.getAnnotation(JsExpression.class).value()); - lines.add("};"); + quote(functionId(method)), parameters), + method.getAnnotation(JsExpression.class).value(), "};")); } - return lines; + return functions; } private static String functionId(Method method) { diff --git a/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitionsTest.java b/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitionsTest.java index fadaa5b294e..cc714d60565 100644 --- a/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitionsTest.java +++ b/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitionsTest.java @@ -21,6 +21,7 @@ import java.nio.file.Files; import java.util.List; import java.util.Set; +import java.util.regex.Pattern; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -179,7 +180,6 @@ void findMissingFromGeneratedFile_answersForWhatTheFileCarries() List.of(GreeterJs.class)), "another version of the declarations is not the declarations"); - Files.writeString(generated.toPath(), carried); Files.delete(generated.toPath()); assertEquals(List.of(GreeterJs.class), TaskGenerateJsDefinitions.findMissingFromGeneratedFile(options, @@ -215,6 +215,62 @@ void updateJsDefinitions_writesWhatIsAskedForBesideWhatTheFileHolds() "and what was added should be part of the module: " + written); } + @Test + void updateJsDefinitions_fileWrittenByAnotherVersion_keepsWhatItHolds() + throws IOException { + // What a file written by another version of this class looks like: + // functions a browser has, and nothing this one recognizes to write + // around + File generated = new File( + FrontendUtils.getFrontendGeneratedFolder(frontendFolder), + FrontendUtils.JS_DEFINITIONS_FILE_NAME); + generated.getParentFile().mkdirs(); + Files.writeString(generated.toPath(), + "window.Vaadin.Flow.jsDefinitions = {\n \"fromsomewhereelse\": async function () {}\n};\n"); + + List> missing = TaskGenerateJsDefinitions + .updateJsDefinitions(options, List.of(CounterJs.class)); + + assertTrue(missing.isEmpty()); + String written = Files.readString(generated.toPath()); + assertTrue(written.contains("fromsomewhereelse"), + "a function a browser has should not be taken out of the file: " + + written); + assertTrue( + written.contains( + JsCall.functionId("this.count = ($0 || 0) + 1", 1)), + "and the one that was asked for should be in it: " + written); + } + + @Test + void updateJsDefinitions_oneMethodEdited_writesOnlyThatFunction() + throws ExecutionFailedException, IOException { + // An interface of two methods, of which one declares something else + // than what the file was written with + task.execute(); + File generated = new File( + FrontendUtils.getFrontendGeneratedFolder(frontendFolder), + FrontendUtils.JS_DEFINITIONS_FILE_NAME); + String unchanged = JsCall.functionId("window.alert('Hello')", 0); + Files.writeString(generated.toPath(), + Files.readString(generated.toPath()).replace( + GREETING_EXPRESSION, "window.alert('what it was')")); + + TaskGenerateJsDefinitions.updateJsDefinitions(options, + List.of(GreeterJs.class)); + + String written = Files.readString(generated.toPath()); + assertEquals(1, countOf(written, unchanged), + "the method that was not edited should not be written again: " + + written); + assertTrue(written.contains(GREETING_EXPRESSION), + "and the edited one should be in the file: " + written); + } + + private static int countOf(String content, String value) { + return content.split(Pattern.quote(value), -1).length - 1; + } + @Test void acceptsItsOwnUpdate() throws ExecutionFailedException { task.execute(); 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 e539b713547..486f3f4ad9e 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 @@ -26,11 +26,13 @@ import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.Stream; +import java.util.stream.StreamSupport; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.parallel.Isolated; import org.mockito.Mockito; +import tools.jackson.databind.JsonNode; import tools.jackson.databind.node.ArrayNode; import tools.jackson.databind.node.ObjectNode; @@ -65,6 +67,7 @@ import com.vaadin.flow.server.VaadinServletRequest; import com.vaadin.flow.server.VaadinSession; import com.vaadin.flow.shared.ApplicationConstants; +import com.vaadin.flow.shared.JsonConstants; import com.vaadin.flow.shared.ui.Dependency; import com.vaadin.flow.shared.ui.LoadMode; @@ -226,13 +229,8 @@ void encodeExecuteJavaScript_jsCall_sendsTheTargetInsteadOfTheScript() { JacksonUtils.nullNode(), target)); assertTrue(JacksonUtils.jsonEquals(expectedJson, json), - "a call of declared JavaScript should carry its target, and no JavaScript: " + "a call of declared JavaScript should carry the function to run, and neither JavaScript nor what declared it: " + json); - assertFalse(json.toString().contains(TestJs.class.getName()), - "a production browser should not be told what declared the JavaScript: " - + json); - assertFalse(json.toString().contains("method"), - "and not what the method is called either: " + json); } @Test @@ -278,6 +276,38 @@ void encodeExecuteJavaScript_subscribedDefinitionCall_addsTheReturnChannels() { assertEquals(1, target.get("arguments").asInt()); } + @Test + void createUidl_productionMode_decidesWhatTheBrowserIsToldAboutACall() + throws Exception { + assertFalse(callInUidl(true).has("debug"), + "a production browser should not be told what declared the JavaScript it runs"); + assertTrue(callInUidl(false).has("debug"), + "and a development one should, or a message about a call can only name a hash"); + } + + /** + * The target of a call of declared JavaScript, as a response written for a + * UI of an application running in the given mode carries it. + */ + private ObjectNode callInUidl(boolean productionMode) throws Exception { + UI ui = initializeUIForDependenciesTest(new UI()); + mocks.getDeploymentConfiguration().setProductionMode(productionMode); + Element element = ElementFactory.createDiv(); + ui.getElement().appendChild(element); + element.executeJs(TestJs.class).method("foo"); + + ArrayNode execute = (ArrayNode) new UidlWriter().createUidl(ui, false) + .get(JsonConstants.UIDL_KEY_EXECUTE); + // Whatever else the response carries runs an expression, which is a + // string where a call of declared JavaScript has its target + return (ObjectNode) StreamSupport.stream(execute.spliterator(), false) + .map(ArrayNode.class::cast) + .map(invocation -> invocation.get(invocation.size() - 1)) + .filter(JsonNode::isObject).findFirst() + .orElseThrow(() -> new AssertionError( + "the response should carry the call: " + execute)); + } + @JsDefinition interface TestJs extends Serializable { @JsExpression("this.method($0)") From cc18beca554761b58d32aac873e114b3b88a3839 Mon Sep 17 00:00:00 2001 From: "totally-not-ai[bot]" <290682512+totally-not-ai[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 05:44:29 +0000 Subject: [PATCH 46/57] feat: send what an invocation runs as a constant, the same way for both The client already caches constants per session, and an expression was the one thing an invocation carried in full every time it ran. What to run is now a constant of the message and the invocation names it, for an expression and for the target of a call of declared JavaScript alike, so the two look the same on the wire and the client reads what to run out of the pool either way. An expression is therefore sent once per session rather than with every call that runs it. The constants of a response are dumped after the invocations are encoded, since that is when the ones they name are registered, and the client imports them before it runs anything. The forced reload during a resynchronization compared the script text of an invocation, which is now a reference, so it resolves it the same way. --- .../client/communication/MessageHandler.ts | 13 +- .../client/flow/ExecuteJavaScriptProcessor.ts | 22 +++- .../flow/ExecuteJavaScriptProcessorTests.ts | 112 ++++++++++-------- .../flow/server/communication/UidlWriter.java | 60 +++++++--- .../server/communication/UidlWriterTest.java | 95 ++++++++++----- 5 files changed, 200 insertions(+), 102 deletions(-) diff --git a/flow-client/src/main/frontend/internal/client/communication/MessageHandler.ts b/flow-client/src/main/frontend/internal/client/communication/MessageHandler.ts index 242073b9a91..e66451122d0 100644 --- a/flow-client/src/main/frontend/internal/client/communication/MessageHandler.ts +++ b/flow-client/src/main/frontend/internal/client/communication/MessageHandler.ts @@ -177,6 +177,17 @@ export class MessageHandler { } } + // What an invocation runs: a constant of this message, or one an earlier + // message put in the pool. The invocation itself only names it. + #whatRuns(invocation: unknown[], valueMap: ValueMap): unknown { + const name = invocation[invocation.length - 1]; + if (typeof name !== 'string') { + return null; + } + const constants = (valueMap.constants ?? {}) as Record; + return constants[name] ?? this.#registry.getConstantPool().get(name); + } + protected handleJSON(valueMap: ValueMap): void { const serverId = getServerId(valueMap); const hasResynchronize = isResynchronize(valueMap); @@ -188,7 +199,7 @@ export class MessageHandler { if (UIDL_KEY_EXECUTE in valueMap) { const commands = valueMap[UIDL_KEY_EXECUTE] as unknown[][]; for (const command of commands) { - if (command.length > 0 && command[0] === 'window.location.reload();') { + if (this.#whatRuns(command, valueMap) === 'window.location.reload();') { Console.warn('Executing forced page reload while a resync request is ongoing.'); window.location.reload(); return; 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 9da9fc35321..1313672e181 100644 --- a/flow-client/src/main/frontend/internal/client/flow/ExecuteJavaScriptProcessor.ts +++ b/flow-client/src/main/frontend/internal/client/flow/ExecuteJavaScriptProcessor.ts @@ -138,7 +138,7 @@ export class ExecuteJavaScriptProcessor { #handleInvocation(invocation: unknown[]): void { const tree = this.#registry.getStateTree(); - // Last item is the script, the rest are parameters. + // Last item names what to run in the constant pool, the rest are parameters. const parameterCount = invocation.length - 1; const parameterNamesAndCode: string[] = []; @@ -167,18 +167,30 @@ export class ExecuteJavaScriptProcessor { } } - const target = invocation[invocation.length - 1]; - if (typeof target === 'object' && target !== null) { + // What to run is a constant of the message, the same way for an + // expression and for a call of declared JavaScript, so an expression the + // server runs again costs a reference rather than its own text. + const whatToRun = this.#registry + .getConstantPool() + .get(invocation[invocation.length - 1] as string); + if (whatToRun === null) { + Console.error( + `No constant for the invocation ${JSON.stringify(invocation)}. Reload the page to pick up the current state.` + ); + return; + } + + if (typeof whatToRun === 'object') { // A call of declared JavaScript: the bundle has the function, the // server sent only 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 JsDefinitionTarget, parameters); + this.invokeFromBundle(whatToRun, parameters); return; } - parameterNamesAndCode.push(target as string); + parameterNamesAndCode.push(whatToRun); this.invoke(parameterNamesAndCode, parameters, nodeParameters); } 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 d1c78a9e22d..33c73458fa9 100644 --- a/flow-client/src/test/frontend/internal/client/flow/ExecuteJavaScriptProcessorTests.ts +++ b/flow-client/src/test/frontend/internal/client/flow/ExecuteJavaScriptProcessorTests.ts @@ -7,9 +7,24 @@ import { StateTree } from '../../../../../main/frontend/internal/client/flow/Sta import { Reactive } from '../../../../../main/frontend/internal/client/flow/reactive/Reactive'; import { NodeFeatures } from '../../../../../main/frontend/internal/flow/internal/nodefeature/NodeFeatures'; import { NodeProperties } from '../../../../../main/frontend/internal/flow/internal/nodefeature/NodeProperties'; +import { ConstantPool } from '../../../../../main/frontend/internal/client/flow/ConstantPool'; import { type RecordedCalls, recordingRegistry } from './stateTreeTestRegistry'; import { TestRegistry, testRegistry } from '../testRegistry'; +// What to run is a constant of the message rather than part of the invocation, +// so a case writes it at the end of an invocation, as the message reads, and +// this puts it in the pool and names it the way the server does. +let nextConstant = 0; +function execute(processor: ExecuteJavaScriptProcessor, registry: TestRegistry, invocations: unknown[][]): void { + const named = invocations.map((invocation) => { + nextConstant += 1; + const key = `constant-${nextConstant}`; + registry.getConstantPool().importFromJson({ [key]: invocation[invocation.length - 1] }); + return [...invocation.slice(0, -1), key]; + }); + processor.execute(named); +} + // Ported from com.vaadin.client.flow.ExecuteJavaScriptProcessorTest and // com.vaadin.client.GwtExecuteJavaScriptElementUtilsTest (the return-channel // case, which drives this class). The cases that exercise the expression @@ -54,6 +69,7 @@ class TestJsProcessor extends ExecuteJavaScriptProcessor { // Registry does. function treeRegistry(services: { existingElementMap?: boolean } = {}): TestRegistry { const registry = new TestRegistry(); + registry.register('ConstantPool', new ConstantPool()); registry.register('StateTree', new StateTree(registry)); if (services.existingElementMap === true) { registry.register('ExistingElementMap', new ExistingElementMap()); @@ -88,13 +104,18 @@ describe('ExecuteJavaScriptProcessor', () => { vaadin.Flow.jsDefinitions[functionId] = fn; } - function processor(): ExecuteJavaScriptProcessor { - return new ExecuteJavaScriptProcessor( - testRegistry({ - StateTree: { getNode: () => null }, - ApplicationConfiguration: { getApplicationId: () => 'ROOT-1', isProductionMode: () => false } - }) - ); + function fixture(): { processor: ExecuteJavaScriptProcessor; registry: TestRegistry } { + const registry = testRegistry({ + ConstantPool: new ConstantPool(), + StateTree: { getNode: () => null }, + ApplicationConfiguration: { getApplicationId: () => 'ROOT-1', isProductionMode: () => false } + }); + return { processor: new ExecuteJavaScriptProcessor(registry), registry }; + } + + function run(invocation: unknown[]): void { + const { processor, registry } = fixture(); + execute(processor, registry, [invocation]); } afterEach(() => { @@ -109,7 +130,7 @@ describe('ExecuteJavaScriptProcessor', () => { }); const element = { tagName: 'div' }; - processor().execute([['Hello', element, { function: GREETING, arguments: 1 }]]); + run(['Hello', element, { function: GREETING, arguments: 1 }]); expect(calls).to.have.lengthOf(1); expect(calls[0].thisArg).to.equal(element); @@ -121,14 +142,7 @@ describe('ExecuteJavaScriptProcessor', () => { const resolved: unknown[] = []; const element = { tagName: 'div' }; - processor().execute([ - [ - element, - (value: unknown) => resolved.push(value), - () => {}, - { function: VALUE, arguments: 0, returns: true } - ] - ]); + run([element, (value: unknown) => resolved.push(value), () => {}, { function: VALUE, 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(); @@ -147,7 +161,7 @@ describe('ExecuteJavaScriptProcessor', () => { // invocation and this client disagree about the signature, which is the // same disagreement as an invocation that carries one parameter too // many. - processor().execute([['Hello', { function: GREETING, arguments: 1 }]]); + run(['Hello', { function: GREETING, arguments: 1 }]); expect(calls).to.equal(0); }); @@ -162,13 +176,7 @@ describe('ExecuteJavaScriptProcessor', () => { const element = { tagName: 'div' }; // Subscribed to, but one channel short of what the target declares. - processor().execute([ - [ - element, - (error: unknown) => errors.push(error), - { function: VALUE, arguments: 0, returns: true } - ] - ]); + run([element, (error: unknown) => errors.push(error), { function: VALUE, arguments: 0, returns: true }]); expect(calls).to.equal(0); // Reported rather than left hanging: the pending result on the server @@ -180,13 +188,11 @@ describe('ExecuteJavaScriptProcessor', () => { const errors: unknown[] = []; const element = { tagName: 'div' }; - processor().execute([ - [ - element, - () => {}, - (error: unknown) => errors.push(error), - { function: 'notinthebundle', arguments: 0, returns: true, debug: 'com.acme.GreeterJs.readValue/0' } - ] + run([ + element, + () => {}, + (error: unknown) => errors.push(error), + { function: 'notinthebundle', arguments: 0, returns: true, debug: 'com.acme.GreeterJs.readValue/0' } ]); expect(errors).to.have.lengthOf(1); @@ -199,9 +205,10 @@ describe('ExecuteJavaScriptProcessor', () => { describe('execute', () => { it('passes the parameters and code of each invocation on', () => { // Ported from execute_parametersAndCodeAreValidAndNoNodeParameters. - const processor = new CollectingExecuteJavaScriptProcessor(treeRegistry()); + const registry = treeRegistry(); + const processor = new CollectingExecuteJavaScriptProcessor(registry); - processor.execute([['script1'], ['param1', 'param2', 'script2']]); + execute(processor, registry, [['script1'], ['param1', 'param2', 'script2']]); expect(processor.parameterNamesAndCodeList).to.have.length(2); expect(processor.parametersList).to.have.length(2); @@ -225,7 +232,7 @@ describe('ExecuteJavaScriptProcessor', () => { const element = document.createElement('div'); node.setDomNode(element); - processor.execute([[{ '@v-node': node.getId() }, '$0']]); + execute(processor, registry, [[{ '@v-node': node.getId() }, '$0']]); expect(processor.nodeParametersList).to.have.length(1); expect(processor.nodeParametersList[0].size).to.equal(1); @@ -243,7 +250,7 @@ describe('ExecuteJavaScriptProcessor', () => { .setValue({ [NodeProperties.TYPE]: NodeProperties.INJECT_BY_ID }); registry.getStateTree().registerNode(node); - processor.execute([[{ '@v-node': node.getId() }, '$0']]); + execute(processor, registry, [[{ '@v-node': node.getId() }, '$0']]); // The invocation has not been executed expect(processor.nodeParametersList).to.have.length(0); @@ -265,7 +272,7 @@ describe('ExecuteJavaScriptProcessor', () => { const node = registeredNode(registry, 31); processor.bound = false; - processor.execute([[{ '@v-node': node.getId() }, '$0']]); + execute(processor, registry, [[{ '@v-node': node.getId() }, '$0']]); expect(processor.nodeParametersList).to.have.length(0); @@ -286,7 +293,7 @@ describe('ExecuteJavaScriptProcessor', () => { const processor = new CollectingExecuteJavaScriptProcessor(registry); const node = registeredNode(registry, 12); - processor.execute([[{ '@v-node': node.getId() }, '$0']]); + execute(processor, registry, [[{ '@v-node': node.getId() }, '$0']]); // The invocation has been executed expect(processor.nodeParametersList).to.have.length(1); @@ -373,6 +380,7 @@ describe('ExecuteJavaScriptProcessor', () => { return { lifecycleStates, registry: testRegistry({ + ConstantPool: new ConstantPool(), StateTree: { getNode: () => null }, ApplicationConfiguration: { getApplicationId: () => 'ROOT-1', isProductionMode: () => false }, UILifecycle: { isTerminated: () => false, setState: (state: UIState) => lifecycleStates.push(state) } @@ -380,6 +388,12 @@ describe('ExecuteJavaScriptProcessor', () => { }; } + // The invocation of one expression that most cases here run + function runExpression(expression: string): void { + const registry = makeRegistry().registry; + execute(new ExecuteJavaScriptProcessor(registry), registry, [[expression]]); + } + afterEach(() => { delete (globalThis as Record).__ejpRan; delete (globalThis as Record).__ejpParam; @@ -391,6 +405,7 @@ describe('ExecuteJavaScriptProcessor', () => { const recorded: RecordedCalls = built.recorded; const tree = new StateTree(built.registry); const registry = testRegistry({ + ConstantPool: new ConstantPool(), StateTree: tree, ApplicationConfiguration: { getApplicationId: () => 'test', isProductionMode: () => false }, UILifecycle: { isTerminated: () => false, setState: () => {} } @@ -400,7 +415,7 @@ describe('ExecuteJavaScriptProcessor', () => { const expectedChannelId = 20; // The @v-return parameter decodes to a callback; the expression calls it. - new ExecuteJavaScriptProcessor(registry).execute([ + execute(new ExecuteJavaScriptProcessor(registry), registry, [ [{ '@v-return': [expectedNodeId, expectedChannelId] }, '$0(2)'] ]); @@ -411,27 +426,26 @@ describe('ExecuteJavaScriptProcessor', () => { it('runs an invocation expression', () => { // Beyond the Java suite. - new ExecuteJavaScriptProcessor(makeRegistry().registry).execute([['globalThis.__ejpRan = true;']]); + runExpression('globalThis.__ejpRan = true;'); expect((globalThis as Record).__ejpRan).to.be.true; }); it('binds invocation parameters to $0, $1, ...', () => { // Beyond the Java suite. - new ExecuteJavaScriptProcessor(makeRegistry().registry).execute([['hello', 'globalThis.__ejpParam = $0;']]); + const registry = makeRegistry().registry; + execute(new ExecuteJavaScriptProcessor(registry), registry, [['hello', 'globalThis.__ejpParam = $0;']]); expect((globalThis as Record).__ejpParam).to.equal('hello'); }); it('exposes the app id with the per-UI suffix stripped', () => { // Beyond the Java suite. - new ExecuteJavaScriptProcessor(makeRegistry().registry).execute([['globalThis.__ejpParam = this.$appId;']]); + runExpression('globalThis.__ejpParam = this.$appId;'); expect((globalThis as Record).__ejpParam).to.equal('ROOT'); }); it('exposes the registry on the context', () => { // Beyond the Java suite. - new ExecuteJavaScriptProcessor(makeRegistry().registry).execute([ - ['globalThis.__ejpParam = this.registry === undefined;'] - ]); + runExpression('globalThis.__ejpParam = this.registry === undefined;'); expect((globalThis as Record).__ejpParam).to.equal(false); }); @@ -439,23 +453,19 @@ describe('ExecuteJavaScriptProcessor', () => { // Beyond the Java suite. // getNode throws when the argument is not a state-node parameter; the // executed code sees that as a thrown ReferenceError. - new ExecuteJavaScriptProcessor(makeRegistry().registry).execute([ - ['try { this.attachExistingElement({}); } catch (e) { globalThis.__ejpParam = e.constructor.name; }'] - ]); + runExpression('try { this.attachExistingElement({}); } catch (e) { globalThis.__ejpParam = e.constructor.name; }'); expect((globalThis as Record).__ejpParam).to.equal('ReferenceError'); }); it('catches exceptions thrown by the executed code', () => { // Beyond the Java suite. - expect(() => - new ExecuteJavaScriptProcessor(makeRegistry().registry).execute([['throw new Error("boom");']]) - ).to.not.throw(); + expect(() => runExpression('throw new Error("boom");')).to.not.throw(); }); it('exposes stopApplication on the context, terminating the UI lifecycle', () => { // Beyond the Java suite. const fixture = makeRegistry(); - new ExecuteJavaScriptProcessor(fixture.registry).execute([['this.stopApplication();']]); + execute(new ExecuteJavaScriptProcessor(fixture.registry), fixture.registry, [['this.stopApplication();']]); expect(fixture.lifecycleStates).to.deep.equal([UIState.TERMINATED]); }); }); 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 fcdfc35ed4e..fca4abe8d29 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 @@ -44,6 +44,8 @@ import com.vaadin.flow.component.internal.PendingJavaScriptInvocation; import com.vaadin.flow.component.internal.UIInternals; import com.vaadin.flow.function.SerializableConsumer; +import com.vaadin.flow.internal.ConstantPool; +import com.vaadin.flow.internal.ConstantPoolKey; import com.vaadin.flow.internal.JacksonCodec; import com.vaadin.flow.internal.JacksonUtils; import com.vaadin.flow.internal.ResourceContentHash; @@ -169,10 +171,6 @@ public ObjectNode createUidl(UI ui, boolean async, boolean resync) { uiInternals.clearPendingStyleSheetRemovals(); } - if (uiInternals.getConstantPool().hasNewConstants()) { - response.set("constants", - uiInternals.getConstantPool().dumpConstants()); - } if (!stateChanges.isEmpty()) { response.set("changes", stateChanges); } @@ -181,8 +179,16 @@ public ObjectNode createUidl(UI ui, boolean async, boolean resync) { .dumpPendingJavaScriptInvocations(); if (!executeJavaScriptList.isEmpty()) { response.set(JsonConstants.UIDL_KEY_EXECUTE, - encodeExecuteJavaScriptList(executeJavaScriptList, !service - .getDeploymentConfiguration().isProductionMode())); + encodeExecuteJavaScriptList(executeJavaScriptList, + uiInternals.getConstantPool(), + !service.getDeploymentConfiguration() + .isProductionMode())); + } + // Dumped after the invocations are encoded, since what each of them + // runs is a constant of this response + if (uiInternals.getConstantPool().hasNewConstants()) { + response.set("constants", + uiInternals.getConstantPool().dumpConstants()); } if (service.getDeploymentConfiguration().isRequestTiming()) { response.set("timings", createPerformanceData(ui)); @@ -309,10 +315,10 @@ private static InputStream getInlineResourceStream(String url, // non-private for testing purposes static ArrayNode encodeExecuteJavaScriptList( List executeJavaScriptList, - boolean withDebugInfo) { + ConstantPool constantPool, boolean withDebugInfo) { return executeJavaScriptList.stream() .map(invocation -> encodeExecuteJavaScript(invocation, - withDebugInfo)) + constantPool, withDebugInfo)) .collect(JacksonUtils.asArray()); } @@ -333,10 +339,12 @@ private static ReturnChannelRegistration createReturnValueChannel( } private static ArrayNode encodeExecuteJavaScript( - PendingJavaScriptInvocation invocation, boolean withDebugInfo) { + PendingJavaScriptInvocation invocation, ConstantPool constantPool, + boolean withDebugInfo) { JsCall jsCall = invocation.getInvocation().getJsCall(); if (jsCall != null) { - return encodeJsCall(invocation, jsCall, withDebugInfo); + return encodeJsCall(invocation, jsCall, constantPool, + withDebugInfo); } List parametersList = invocation.getInvocation() @@ -380,13 +388,28 @@ private static ArrayNode encodeExecuteJavaScript( //@formatter:on } - // [argument1, argument2, ..., script] - return Stream - .concat(parameters.map(JacksonCodec::encodeWithTypeInfo), - Stream.of(JacksonUtils.createNode(expression))) + // [argument1, argument2, ..., what to run] + return Stream.concat(parameters.map(JacksonCodec::encodeWithTypeInfo), + Stream.of(JacksonUtils.createNode(constantOf( + JacksonUtils.createNode(expression), constantPool)))) .collect(JacksonUtils.asArray()); } + /** + * Registers what an invocation runs with the constant pool and answers with + * what names it in the invocation. + *

+ * An expression is then sent once per session rather than with every + * invocation that runs it, and the target of an invocation of declared + * JavaScript is a constant like any other. The two kinds of invocation look + * the same on the wire, and the client reads what to run out of the pool + * either way. + */ + private static String constantOf(JsonNode whatToRun, + ConstantPool constantPool) { + return constantPool.getConstantId(new ConstantPoolKey(whatToRun)); + } + /** * Encodes a call made through a JavaScript definition as * [argument1, ..., element, successChannel, errorChannel, target], @@ -405,7 +428,7 @@ private static ArrayNode encodeExecuteJavaScript( */ private static ArrayNode encodeJsCall( PendingJavaScriptInvocation invocation, JsCall call, - boolean withDebugInfo) { + ConstantPool constantPool, boolean withDebugInfo) { Stream parameters = invocation.getInvocation().getParameters() .stream(); @@ -434,8 +457,11 @@ private static ArrayNode encodeJsCall( target.put(JsonConstants.UIDL_KEY_JS_FUNCTION_RETURNS, true); } - return Stream.concat(parameters.map(JacksonCodec::encodeWithTypeInfo), - Stream.of(target)).collect(JacksonUtils.asArray()); + return Stream + .concat(parameters.map(JacksonCodec::encodeWithTypeInfo), + Stream.of(JacksonUtils + .createNode(constantOf(target, constantPool)))) + .collect(JacksonUtils.asArray()); } /** 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 486f3f4ad9e..1542dd10f6b 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 @@ -26,7 +26,6 @@ import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.Stream; -import java.util.stream.StreamSupport; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; @@ -51,6 +50,7 @@ import com.vaadin.flow.dom.Element; import com.vaadin.flow.dom.ElementFactory; import com.vaadin.flow.internal.BundleUtils; +import com.vaadin.flow.internal.ConstantPool; import com.vaadin.flow.internal.JacksonUtils; import com.vaadin.flow.internal.StateTree; import com.vaadin.flow.js.JsCall; @@ -67,7 +67,6 @@ import com.vaadin.flow.server.VaadinServletRequest; import com.vaadin.flow.server.VaadinSession; import com.vaadin.flow.shared.ApplicationConstants; -import com.vaadin.flow.shared.JsonConstants; import com.vaadin.flow.shared.ui.Dependency; import com.vaadin.flow.shared.ui.LoadMode; @@ -192,20 +191,41 @@ void testEncodeExecuteJavaScript_npmMode() { element.getNode(), invocation)) .collect(Collectors.toList()); - ArrayNode json = UidlWriter - .encodeExecuteJavaScriptList(executeJavaScriptList, false); + ConstantPool constantPool = new ConstantPool(); + ArrayNode json = UidlWriter.encodeExecuteJavaScriptList( + executeJavaScriptList, constantPool, false); + ObjectNode constants = constantPool.dumpConstants(); ArrayNode expectedJson = JacksonUtils.createArray( JacksonUtils.createArray( // Null since element is not attached JacksonUtils.nullNode(), - JacksonUtils.createNode("$0.focus()")), + JacksonUtils.createNode( + nameOfWhatRuns("$0.focus()", constants))), JacksonUtils.createArray( JacksonUtils.createNode("Lives remaining:"), JacksonUtils.createNode(3), - JacksonUtils.createNode("console.log($0, $1)"))); + JacksonUtils.createNode(nameOfWhatRuns( + "console.log($0, $1)", constants)))); - assertTrue(JacksonUtils.jsonEquals(expectedJson, json)); + assertTrue(JacksonUtils.jsonEquals(expectedJson, json), + "an invocation should name what it runs among the constants of the message: " + + json + " " + constants); + } + + /** + * What names the given script among the given constants, which is what an + * invocation that runs it carries instead of the script itself. + */ + private static String nameOfWhatRuns(Object whatRuns, + ObjectNode constants) { + return JacksonUtils.getKeys(constants).stream() + .filter(key -> whatRuns instanceof JsonNode node + ? JacksonUtils.jsonEquals(node, constants.get(key)) + : whatRuns.equals(constants.get(key).asString())) + .findFirst() + .orElseThrow(() -> new AssertionError("The constants " + + constants + " should carry " + whatRuns)); } @Test @@ -216,9 +236,11 @@ void encodeExecuteJavaScript_jsCall_sendsTheTargetInsteadOfTheScript() { JavaScriptInvocation invocation = new JavaScriptInvocation(call, call.getExpression(), "foo", element); + ConstantPool constantPool = new ConstantPool(); ArrayNode json = UidlWriter.encodeExecuteJavaScriptList(List.of( new PendingJavaScriptInvocation(element.getNode(), invocation)), - false); + constantPool, false); + ObjectNode constants = constantPool.dumpConstants(); ObjectNode target = JacksonUtils.createObjectNode(); target.put("function", JsCall.functionId("this.method($0)", 1)); @@ -226,11 +248,15 @@ void encodeExecuteJavaScript_jsCall_sendsTheTargetInsteadOfTheScript() { ArrayNode expectedJson = JacksonUtils.createArray( JacksonUtils.createArray(JacksonUtils.createNode("foo"), // Null since element is not attached - JacksonUtils.nullNode(), target)); + JacksonUtils.nullNode(), JacksonUtils.createNode( + nameOfWhatRuns(target, constants)))); assertTrue(JacksonUtils.jsonEquals(expectedJson, json), - "a call of declared JavaScript should carry the function to run, and neither JavaScript nor what declared it: " - + json); + "a call of declared JavaScript should name a target, the same way an expression names a script: " + + json + " " + constants); + assertFalse(constants.toString().contains(TestJs.class.getName()), + "and the target should carry neither JavaScript nor what declared it: " + + constants); } @Test @@ -241,14 +267,30 @@ void encodeExecuteJavaScript_jsCallOutsideProductionMode_addsWhatToCallIt() { JavaScriptInvocation invocation = new JavaScriptInvocation(call, call.getExpression(), "foo", element); - ArrayNode json = UidlWriter.encodeExecuteJavaScriptList(List.of( + ConstantPool constantPool = new ConstantPool(); + UidlWriter.encodeExecuteJavaScriptList(List.of( new PendingJavaScriptInvocation(element.getNode(), invocation)), - true); + constantPool, true); + ObjectNode constants = constantPool.dumpConstants(); assertEquals(TestJs.class.getName() + ".method/1", - ((ObjectNode) ((ArrayNode) json.get(0)).get(2)).get("debug") - .asString(), - "a message about the call should be able to name it: " + json); + targetIn(constants).get("debug").asString(), + "a message about the call should be able to name it: " + + constants); + } + + /** + * The one target among the given constants, which is what a call of + * declared JavaScript runs. + */ + private static ObjectNode targetIn(ObjectNode constants) { + return (ObjectNode) JacksonUtils.getKeys(constants).stream() + .map(constants::get) + .filter(constant -> constant.isObject() + && constant.has("function")) + .findFirst() + .orElseThrow(() -> new AssertionError("The constants " + + constants + " should carry a target")); } @Test @@ -263,14 +305,15 @@ void encodeExecuteJavaScript_subscribedDefinitionCall_addsTheReturnChannels() { pending.then(value -> { }); - ArrayNode json = UidlWriter - .encodeExecuteJavaScriptList(List.of(pending), false); + ConstantPool constantPool = new ConstantPool(); + ArrayNode json = UidlWriter.encodeExecuteJavaScriptList( + List.of(pending), constantPool, false); 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); + ObjectNode target = targetIn(constantPool.dumpConstants()); assertTrue(target.get("returns").asBoolean(), "the target should tell the client that the call is subscribed to"); assertEquals(1, target.get("arguments").asInt()); @@ -296,16 +339,12 @@ private ObjectNode callInUidl(boolean productionMode) throws Exception { ui.getElement().appendChild(element); element.executeJs(TestJs.class).method("foo"); - ArrayNode execute = (ArrayNode) new UidlWriter().createUidl(ui, false) - .get(JsonConstants.UIDL_KEY_EXECUTE); - // Whatever else the response carries runs an expression, which is a + ObjectNode response = new UidlWriter().createUidl(ui, false); + // An invocation names what it runs among the constants of the + // response; whatever else it carries runs an expression, which is a // string where a call of declared JavaScript has its target - return (ObjectNode) StreamSupport.stream(execute.spliterator(), false) - .map(ArrayNode.class::cast) - .map(invocation -> invocation.get(invocation.size() - 1)) - .filter(JsonNode::isObject).findFirst() - .orElseThrow(() -> new AssertionError( - "the response should carry the call: " + execute)); + ObjectNode constants = (ObjectNode) response.get("constants"); + return targetIn(constants); } @JsDefinition From 13fc0babd2456cf68c703cc423bd4d7f603b2f44 Mon Sep 17 00:00:00 2001 From: "totally-not-ai[bot]" <290682512+totally-not-ai[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 05:54:39 +0000 Subject: [PATCH 47/57] fix: read what an invocation runs through the constants in the MPR fix-up too The fix-up for the hash fragment of an MPR v7 location scanned the invocations for the script that pushes a state, which is a reference to a constant now, and it added an invocation carrying a script, which a client can no longer run. It reads what an invocation runs out of the constants of the response, and what it adds is registered with the constant pool of the UI and sent with the response like any other constant. The v7 UIDL it rewrites is a parameter, which is carried as it was. The cases for it went through the same shape the wire has now, rather than the scripts of before, so they exercise the fix-up instead of passing around it. Also pinned: a script is named the same way and sent once however many invocations run it, an invocation whose constant never arrived runs nothing, and what an invocation runs is read from the message that carries it or from the pool, which is what the forced reload during a resynchronization depends on. --- .../client/communication/MessageHandler.ts | 39 +++++++--- .../client/flow/ExecuteJavaScriptProcessor.ts | 12 +-- .../communication/MessageHandlerTests.ts | 20 ++++- .../flow/ExecuteJavaScriptProcessorTests.ts | 23 +++++- .../communication/UidlRequestHandler.java | 76 ++++++++++++++++--- .../flow/server/communication/UidlWriter.java | 16 ++-- .../communication/UidlRequestHandlerTest.java | 32 ++++++-- .../server/communication/UidlWriterTest.java | 29 +++++++ 8 files changed, 204 insertions(+), 43 deletions(-) diff --git a/flow-client/src/main/frontend/internal/client/communication/MessageHandler.ts b/flow-client/src/main/frontend/internal/client/communication/MessageHandler.ts index e66451122d0..e2fdf15da7f 100644 --- a/flow-client/src/main/frontend/internal/client/communication/MessageHandler.ts +++ b/flow-client/src/main/frontend/internal/client/communication/MessageHandler.ts @@ -25,6 +25,7 @@ // EagerDependencyTracker, and the helpers above; everything else is a // Registry contract. +import type { ConstantPool } from '../flow/ConstantPool'; import type { StateNode } from '../flow/StateNode'; import type { Registry } from '../Registry'; import type { Command } from '../Command'; @@ -177,17 +178,6 @@ export class MessageHandler { } } - // What an invocation runs: a constant of this message, or one an earlier - // message put in the pool. The invocation itself only names it. - #whatRuns(invocation: unknown[], valueMap: ValueMap): unknown { - const name = invocation[invocation.length - 1]; - if (typeof name !== 'string') { - return null; - } - const constants = (valueMap.constants ?? {}) as Record; - return constants[name] ?? this.#registry.getConstantPool().get(name); - } - protected handleJSON(valueMap: ValueMap): void { const serverId = getServerId(valueMap); const hasResynchronize = isResynchronize(valueMap); @@ -199,7 +189,12 @@ export class MessageHandler { if (UIDL_KEY_EXECUTE in valueMap) { const commands = valueMap[UIDL_KEY_EXECUTE] as unknown[][]; for (const command of commands) { - if (this.#whatRuns(command, valueMap) === 'window.location.reload();') { + const runs = whatInvocationRuns( + command, + (valueMap.constants ?? {}) as Record, + this.#registry.getConstantPool() + ); + if (runs === 'window.location.reload();') { Console.warn('Executing forced page reload while a resync request is ongoing.'); window.location.reload(); return; @@ -668,6 +663,26 @@ export class MessageHandler { * @param jsonText - The JSON to parse * @returns A ValueMap created from the JSON */ +/** + * What an invocation runs, which the invocation names rather than carries. + * + * @param invocation - the invocation, whose last element is the name + * @param constants - the constants of the message carrying the invocation + * @param constantPool - what earlier messages put in the pool + * @returns what to run, or `null` when nothing is named + */ +export function whatInvocationRuns( + invocation: unknown[], + constants: Record, + constantPool: ConstantPool +): unknown { + const name = invocation[invocation.length - 1]; + if (typeof name !== 'string') { + return null; + } + return constants[name] ?? constantPool.get(name); +} + export function parseJson(jsonText: string | null): ValueMap | null { if (jsonText === null) { return null; 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 1313672e181..9292847b84b 100644 --- a/flow-client/src/main/frontend/internal/client/flow/ExecuteJavaScriptProcessor.ts +++ b/flow-client/src/main/frontend/internal/client/flow/ExecuteJavaScriptProcessor.ts @@ -287,7 +287,9 @@ 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) { - const message = `Expected ${expectedCount} parameters for ${nameOf(target)} but the invocation carries ${parameters.length}. Reload the page to pick up the current signature.`; + const message = `Expected ${expectedCount} parameters for ${nameOf(target)} 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 @@ -307,7 +309,9 @@ export class ExecuteJavaScriptProcessor { const fn = findDeclaredFunction(target.function); if (fn === undefined) { - const message = `No JavaScript in the bundle for ${nameOf(target)}. The JavaScript definition is annotated with @JsDefinition, but the build did not collect it.`; + const message = `No JavaScript in the bundle for ${nameOf( + target + )}. The JavaScript definition is annotated with @JsDefinition, but the build did not collect it.`; Console.error(message); onError?.(message); return; @@ -323,9 +327,7 @@ export class ExecuteJavaScriptProcessor { } } catch (exception) { Console.reportStacktrace(exception); - Console.error( - `Exception is thrown while running ${nameOf(target)}. Stacktrace will be dumped separately.` - ); + Console.error(`Exception is thrown while running ${nameOf(target)}. Stacktrace will be dumped separately.`); onError?.(`${exception}`); } } diff --git a/flow-client/src/test/frontend/internal/client/communication/MessageHandlerTests.ts b/flow-client/src/test/frontend/internal/client/communication/MessageHandlerTests.ts index 77b4de1330d..3ef651aaf30 100644 --- a/flow-client/src/test/frontend/internal/client/communication/MessageHandlerTests.ts +++ b/flow-client/src/test/frontend/internal/client/communication/MessageHandlerTests.ts @@ -5,7 +5,12 @@ import type { ResourceLoadListener } from '../../../../../main/frontend/internal/client/ResourceRegistry'; import { expect } from '@open-wc/testing'; -import { MessageHandler, parseJson } from '../../../../../main/frontend/internal/client/communication/MessageHandler'; +import { + MessageHandler, + parseJson, + whatInvocationRuns +} from '../../../../../main/frontend/internal/client/communication/MessageHandler'; +import { ConstantPool } from '../../../../../main/frontend/internal/client/flow/ConstantPool'; import { DependencyLoader } from '../../../../../main/frontend/internal/client/DependencyLoader'; import { ResourceLoader } from '../../../../../main/frontend/internal/client/ResourceLoader'; import { runWhenEagerDependenciesLoaded } from '../../../../../main/frontend/internal/client/EagerDependencyTracker'; @@ -516,6 +521,19 @@ describe('MessageHandler', () => { expect(profiling[0]).to.be.at.least(0); }); + it('reads what an invocation runs from this message or from the pool', () => { + // Which decides whether a forced reload during a resynchronization is + // seen, and the script of an invocation can come from either: the + // message that carries it, or the one that first sent it. + const pool = new ConstantPool(); + pool.importFromJson({ earlier: 'window.location.reload();' }); + + expect(whatInvocationRuns([{}, 'now'], { now: 'history.back();' }, pool)).to.equal('history.back();'); + expect(whatInvocationRuns([{}, 'earlier'], {}, pool)).to.equal('window.location.reload();'); + expect(whatInvocationRuns([{}, 'neither'], {}, pool)).to.be.null; + expect(whatInvocationRuns([], {}, pool)).to.be.null; + }); + it('keeps processing a message whose stylesheetRemovals is null', () => { const registry = makeRegistry(); const handler = new MessageHandler(registry.registry); 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 33c73458fa9..b6c9fb73663 100644 --- a/flow-client/src/test/frontend/internal/client/flow/ExecuteJavaScriptProcessorTests.ts +++ b/flow-client/src/test/frontend/internal/client/flow/ExecuteJavaScriptProcessorTests.ts @@ -142,7 +142,12 @@ describe('ExecuteJavaScriptProcessor', () => { const resolved: unknown[] = []; const element = { tagName: 'div' }; - run([element, (value: unknown) => resolved.push(value), () => {}, { function: VALUE, arguments: 0, returns: true }]); + run([ + element, + (value: unknown) => resolved.push(value), + () => {}, + { function: VALUE, 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(); @@ -224,6 +229,18 @@ describe('ExecuteJavaScriptProcessor', () => { expect(processor.nodeParametersList[1].size).to.equal(0); }); + it('runs nothing for an invocation whose constant is not there', () => { + // A message that named a constant of one that never arrived, or arrived + // out of order: running the name as a script is the one thing that must + // not happen. + const registry = treeRegistry(); + const processor = new CollectingExecuteJavaScriptProcessor(registry); + + processor.execute([['neverimported']]); + + expect(processor.parameterNamesAndCodeList).to.have.length(0); + }); + it('passes a node parameter as the element it is bound to', () => { // Ported from execute_nodeParametersAreCorrectlyPassed. const registry = treeRegistry({ existingElementMap: true }); @@ -453,7 +470,9 @@ describe('ExecuteJavaScriptProcessor', () => { // Beyond the Java suite. // getNode throws when the argument is not a state-node parameter; the // executed code sees that as a thrown ReferenceError. - runExpression('try { this.attachExistingElement({}); } catch (e) { globalThis.__ejpParam = e.constructor.name; }'); + runExpression( + 'try { this.attachExistingElement({}); } catch (e) { globalThis.__ejpParam = e.constructor.name; }' + ); expect((globalThis as Record).__ejpParam).to.equal('ReferenceError'); }); diff --git a/flow-server/src/main/java/com/vaadin/flow/server/communication/UidlRequestHandler.java b/flow-server/src/main/java/com/vaadin/flow/server/communication/UidlRequestHandler.java index 9c122728211..b298b168ac4 100644 --- a/flow-server/src/main/java/com/vaadin/flow/server/communication/UidlRequestHandler.java +++ b/flow-server/src/main/java/com/vaadin/flow/server/communication/UidlRequestHandler.java @@ -26,11 +26,14 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import tools.jackson.databind.JsonNode; import tools.jackson.databind.node.ArrayNode; import tools.jackson.databind.node.JsonNodeType; import tools.jackson.databind.node.ObjectNode; import com.vaadin.flow.component.UI; +import com.vaadin.flow.internal.ConstantPool; +import com.vaadin.flow.internal.ConstantPoolKey; import com.vaadin.flow.internal.JacksonUtils; import com.vaadin.flow.internal.JsonDecodingException; import com.vaadin.flow.server.HandlerHelper; @@ -85,6 +88,8 @@ public class UidlRequestHandler extends SynchronizedRequestHandler private static final String RPC = RPC_INVOCATIONS; private static final String LOCATION = RPC_NAVIGATION_LOCATION; private static final String CHANGES = "changes"; + + private static final String CONSTANTS = "constants"; private static final String EXECUTE = UIDL_KEY_EXECUTE; @Override @@ -208,7 +213,7 @@ private void writeSyncError(SystemMessages systemMessages, void writeUidl(UI ui, Writer writer, boolean resync) throws IOException { ObjectNode uidl = createUidl(ui, resync); - removeOffendingMprHashFragment(uidl); + removeOffendingMprHashFragment(ui, uidl); String responseString = uidl.toString(); ui.getInternals().setLastRequestResponse(responseString); @@ -282,7 +287,7 @@ public static void commitJsonResponse(VaadinResponse response, String json) outputStream.flush(); } - private void removeOffendingMprHashFragment(ObjectNode uidl) { + private void removeOffendingMprHashFragment(UI ui, ObjectNode uidl) { if (!uidl.has(EXECUTE)) { return; } @@ -292,15 +297,18 @@ private void removeOffendingMprHashFragment(ObjectNode uidl) { int idx = -1; for (int i = 0; i < exec.size(); i++) { ArrayNode arr = (ArrayNode) exec.get(i); - for (int j = 0; j < arr.size(); j++) { + String runs = whatRuns(arr, uidl); + if (runs != null && runs.contains("history.pushState")) { + idx = i; + } + // Everything but the last element is a parameter, and the v7 UIDL + // this reaches into is one of them. The last one names what the + // invocation runs rather than being it. + for (int j = 0; j < arr.size() - 1; j++) { if (!arr.get(j).getNodeType().equals(JsonNodeType.STRING)) { continue; } String script = arr.get(j).asString(); - if (script.contains("history.pushState")) { - idx = i; - continue; - } if (!script.startsWith(SYNC_ID)) { continue; } @@ -319,9 +327,11 @@ private void removeOffendingMprHashFragment(ObjectNode uidl) { if (location != null) { ArrayNode arr = JacksonUtils.createArrayNode(); arr.add(""); - arr.add(String - .format(location.startsWith("http") ? PUSH_STATE_LOCATION - : PUSH_STATE_HASH, location)); + arr.add(asConstant(ui, uidl, + String.format( + location.startsWith("http") ? PUSH_STATE_LOCATION + : PUSH_STATE_HASH, + location))); if (idx >= 0) { exec.set(idx, arr); } else { @@ -331,6 +341,52 @@ private void removeOffendingMprHashFragment(ObjectNode uidl) { } } + /** + * What the given invocation runs, as this response carries it. + *

+ * An invocation names what it runs among the constants of the response that + * sends it, so this answers for one that runs something the client has not + * been sent before, which is what an invocation of a location that is being + * navigated to is. One that runs something the client already has is + * answered for with null, and the push state of the corrected + * location is then added to the response rather than replacing it. + */ + private static String whatRuns(ArrayNode invocation, ObjectNode uidl) { + if (invocation.isEmpty() || !uidl.has(CONSTANTS)) { + return null; + } + JsonNode name = invocation.get(invocation.size() - 1); + if (!name.getNodeType().equals(JsonNodeType.STRING)) { + return null; + } + JsonNode constant = uidl.get(CONSTANTS).get(name.asString()); + return constant != null + && constant.getNodeType().equals(JsonNodeType.STRING) + ? constant.asString() + : null; + } + + /** + * Registers the given script with the constant pool of the given UI, puts + * it among the constants of the given response when the client does not + * have it yet, and answers with what names it - which is what an invocation + * carries instead of the script. + */ + private static String asConstant(UI ui, ObjectNode uidl, String script) { + ConstantPool constantPool = ui.getInternals().getConstantPool(); + String name = constantPool.getConstantId( + new ConstantPoolKey(JacksonUtils.createNode(script))); + if (constantPool.hasNewConstants()) { + ObjectNode constants = uidl.has(CONSTANTS) + ? (ObjectNode) uidl.get(CONSTANTS) + : uidl.putObject(CONSTANTS); + constantPool.dumpConstants().properties() + .forEach(constant -> constants.set(constant.getKey(), + constant.getValue())); + } + return name; + } + private String removeHashInV7Uidl(ObjectNode json) { String removed = null; ArrayNode changes = (ArrayNode) json.get(CHANGES); 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 fca4abe8d29..218322c0a13 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 @@ -389,9 +389,11 @@ private static ArrayNode encodeExecuteJavaScript( } // [argument1, argument2, ..., what to run] - return Stream.concat(parameters.map(JacksonCodec::encodeWithTypeInfo), - Stream.of(JacksonUtils.createNode(constantOf( - JacksonUtils.createNode(expression), constantPool)))) + return Stream + .concat(parameters.map(JacksonCodec::encodeWithTypeInfo), + Stream.of( + constantOf(JacksonUtils.createNode(expression), + constantPool))) .collect(JacksonUtils.asArray()); } @@ -405,9 +407,10 @@ private static ArrayNode encodeExecuteJavaScript( * the same on the wire, and the client reads what to run out of the pool * either way. */ - private static String constantOf(JsonNode whatToRun, + private static JsonNode constantOf(JsonNode whatToRun, ConstantPool constantPool) { - return constantPool.getConstantId(new ConstantPoolKey(whatToRun)); + return JacksonUtils.createNode( + constantPool.getConstantId(new ConstantPoolKey(whatToRun))); } /** @@ -459,8 +462,7 @@ private static ArrayNode encodeJsCall( return Stream .concat(parameters.map(JacksonCodec::encodeWithTypeInfo), - Stream.of(JacksonUtils - .createNode(constantOf(target, constantPool)))) + Stream.of(constantOf(target, constantPool))) .collect(JacksonUtils.asArray()); } diff --git a/flow-server/src/test/java/com/vaadin/flow/server/communication/UidlRequestHandlerTest.java b/flow-server/src/test/java/com/vaadin/flow/server/communication/UidlRequestHandlerTest.java index 7c504e222c5..46b9a950955 100644 --- a/flow-server/src/test/java/com/vaadin/flow/server/communication/UidlRequestHandlerTest.java +++ b/flow-server/src/test/java/com/vaadin/flow/server/communication/UidlRequestHandlerTest.java @@ -288,7 +288,9 @@ void should_changeURL_when_v7LocationProvided() throws Exception { assertEquals( "setTimeout(() => history.pushState(null, null, 'http://localhost:9998/#!away'));", - uidl.get("execute").get(1).get(1).textValue()); + whatRuns(uidl, 1), + "the push state of the corrected location should replace the one the response carried: " + + uidl); } @Test @@ -308,7 +310,9 @@ void should_updateHash_when_v7LocationNotProvided() throws Exception { assertEquals( "setTimeout(() => history.pushState(null, null, location.pathname + location.search + '#!away'));", - uidl.get("execute").get(1).get(1).textValue()); + whatRuns(uidl, 1), + "the push state of the corrected hash should replace the one the response carried: " + + uidl); } @Test @@ -524,6 +528,16 @@ protected ServerRpcHandler createRpcHandler() { "Response should have null message"); } + /** + * What the invocation at the given index of the given response runs, which + * the invocation names among the constants of the response. + */ + private static String whatRuns(ObjectNode uidl, int index) { + ArrayNode invocation = (ArrayNode) uidl.get("execute").get(index); + String name = invocation.get(invocation.size() - 1).asString(); + return uidl.get("constants").get(name).asString(); + } + private ObjectNode generateUidl(boolean withLocation, boolean withHash) { // @formatter:off @@ -533,11 +547,17 @@ private ObjectNode generateUidl(boolean withLocation, boolean withHash) { " \"clientId\": 3," + " \"changes\": []," + " \"execute\": [" + - " [\"\", \"document.title = $0\"]," + - " [\"\", \"setTimeout(() => window.history.pushState(null, '', $0))\"]," + - " [[0, 16], \"___PLACE_FOR_V7_UIDL___\", \"$0.firstElementChild.setResponse($1)\"]," + - " [1,null,[0, 16], \"return (function() { this.$server['}p']($0, true, $1)}).apply($2)\"]" + + " [\"\", \"title\"]," + + " [\"\", \"pushState\"]," + + " [[0, 16], \"___PLACE_FOR_V7_UIDL___\", \"setResponse\"]," + + " [1,null,[0, 16], \"callServer\"]" + " ]," + + " \"constants\": {" + + " \"title\": \"document.title = $0\"," + + " \"pushState\": \"setTimeout(() => window.history.pushState(null, '', $0))\"," + + " \"setResponse\": \"$0.firstElementChild.setResponse($1)\"," + + " \"callServer\": \"return (function() { this.$server['}p']($0, true, $1)}).apply($2)\"" + + " }," + " \"timings\": []" + "}"); 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 1542dd10f6b..033ca2c1ed3 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 @@ -228,6 +228,35 @@ private static String nameOfWhatRuns(Object whatRuns, + constants + " should carry " + whatRuns)); } + @Test + void encodeExecuteJavaScript_sameScriptTwice_sentOnceAndNamedTwice() { + Element element = ElementFactory.createDiv(); + ConstantPool constantPool = new ConstantPool(); + + ArrayNode first = UidlWriter.encodeExecuteJavaScriptList( + List.of(new PendingJavaScriptInvocation(element.getNode(), + new JavaScriptInvocation("$0.focus()", element))), + constantPool, false); + constantPool.dumpConstants(); + ArrayNode second = UidlWriter.encodeExecuteJavaScriptList( + List.of(new PendingJavaScriptInvocation(element.getNode(), + new JavaScriptInvocation("$0.focus()", element))), + constantPool, false); + + assertEquals(nameOfWhatRuns(first), nameOfWhatRuns(second), + "the same script should be named the same way"); + assertFalse(constantPool.hasNewConstants(), + "and sent once, not with every invocation that runs it"); + } + + /** + * What the first invocation of the given list names as the thing it runs. + */ + private static String nameOfWhatRuns(ArrayNode invocations) { + ArrayNode invocation = (ArrayNode) invocations.get(0); + return invocation.get(invocation.size() - 1).asString(); + } + @Test void encodeExecuteJavaScript_jsCall_sendsTheTargetInsteadOfTheScript() { Element element = ElementFactory.createDiv(); From 123eac8c88ddf8c9c235aface50d0f48adfe4850 Mon Sep 17 00:00:00 2001 From: "totally-not-ai[bot]" <290682512+totally-not-ai[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 06:57:25 +0000 Subject: [PATCH 48/57] refactor: send only the function to run, and name it in the bundle instead The target of a call carried the function, the number of arguments and, outside production, what declared it. Only the first of those is something the client cannot work out: the function of the bundle takes the arguments of the call, so how many there are is its own arity, and what follows them is the element and, when the call is subscribed to, the two channels. An invocation now ends with the identifier of the function and nothing else, the same way one that runs an expression ends with the expression. What a message calls a function by moves to where the function is: a development bundle registers what a developer wrote next to it, and a production bundle carries the functions alone, so nothing of the Java reaches a browser either way. The client tells the two apart by the shape of the constant, since the identifier of a function is a hash and nothing else the server sends looks like one, and it reports an identifier the bundle does not carry rather than trying to run it. --- .../frontend/TaskGenerateJsDefinitions.java | 77 ++++++++--- .../TaskGenerateJsDefinitionsTest.java | 20 ++- .../client/flow/ExecuteJavaScriptProcessor.ts | 127 +++++++++--------- .../flow/ExecuteJavaScriptProcessorTests.ts | 82 ++++++----- .../main/java/com/vaadin/flow/js/JsCall.java | 12 -- .../flow/server/communication/UidlWriter.java | 60 ++++----- .../com/vaadin/flow/shared/JsonConstants.java | 29 ---- .../server/communication/UidlWriterTest.java | 108 +++------------ 8 files changed, 229 insertions(+), 286 deletions(-) diff --git a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitions.java b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitions.java index 61bbbb4f173..6fd864d60a1 100644 --- a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitions.java +++ b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitions.java @@ -50,6 +50,10 @@ * unsafe-eval, the JavaScript an application can be made to run is * known when it is built, and what declared it in Java stays there. *

+ * Outside production mode the file also registers what a developer wrote for + * each function, so that a message about a call in the browser names it rather + * than a hash. A production bundle carries the functions alone. + *

* For internal use only. May be renamed or removed in a future release. */ public class TaskGenerateJsDefinitions extends AbstractTaskClientGenerator { @@ -59,6 +63,10 @@ public class TaskGenerateJsDefinitions extends AbstractTaskClientGenerator { "window.Vaadin.Flow = window.Vaadin.Flow || {};", "window.Vaadin.Flow.jsDefinitions = window.Vaadin.Flow.jsDefinitions || {};"); + // What a message about a call names it by, which is of no use to a browser + // running the application and is left out of a production bundle + private static final String NAMES = "window.Vaadin.Flow.jsDefinitionNames = window.Vaadin.Flow.jsDefinitionNames || {};"; + // 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 @@ -76,8 +84,8 @@ public class TaskGenerateJsDefinitions extends AbstractTaskClientGenerator { @Override protected String getFileContent() { - return renderFileContent(options.getClassFinder() - .getAnnotatedClasses(JsDefinition.class)); + return renderFileContent(options.getClassFinder().getAnnotatedClasses( + JsDefinition.class), !options.isProductionMode()); } /** @@ -90,13 +98,20 @@ protected String getFileContent() { * * @param definitions * the JavaScript definitions to render, not null + * @param withNames + * whether to register what a message about a call names it by, + * which is for a development bundle * @return the content of the generated file */ - static String renderFileContent(Collection> definitions) { + static String renderFileContent(Collection> definitions, + boolean withNames) { List lines = new ArrayList<>(HEADER); + if (withNames) { + lines.add(NAMES); + } definitions.stream().sorted(Comparator.comparing(Class::getName)) .forEach(definition -> lines - .addAll(renderDefinitionLines(definition))); + .addAll(renderDefinitionLines(definition, withNames))); lines.addAll(FOOTER); return String.join(System.lineSeparator(), lines); } @@ -116,7 +131,8 @@ public static List> findMissingFromGeneratedFile(Options options, Collection> definitions) { String generated = readGeneratedFile(options); return definitions.stream() - .filter(definition -> !isInGeneratedFile(definition, generated)) + .filter(definition -> !isInGeneratedFile(definition, generated, + !options.isProductionMode())) .toList(); } @@ -146,7 +162,8 @@ public static List> findMissingFromGeneratedFile(Options options, public static List> updateJsDefinitions(Options options, Collection> definitions) { String generated = readGeneratedFile(options); - String content = withMissingEntries(generated, definitions); + String content = withMissingEntries(generated, definitions, + !options.isProductionMode()); TaskGenerateJsDefinitions task = new TaskGenerateJsDefinitions(options); try { @@ -155,8 +172,9 @@ public static List> updateJsDefinitions(Options options, getLogger().debug("Could not write {}", task.getGeneratedFile(), e); // The file is as it was, so only what it was already missing is // missing now - return definitions.stream().filter( - definition -> !isInGeneratedFile(definition, generated)) + return definitions.stream() + .filter(definition -> !isInGeneratedFile(definition, + generated, !options.isProductionMode())) .toList(); } // Everything asked for went into the content that was written @@ -171,11 +189,11 @@ public static List> updateJsDefinitions(Options options, * nothing calling it. */ private static boolean isInGeneratedFile(Class definition, - String generated) { + String generated, boolean withNames) { if (generated == null) { return false; } - List declared = renderDefinitionLines(definition); + List declared = renderDefinitionLines(definition, withNames); if (declared.isEmpty()) { // Declares no JavaScript, so there is nothing to carry return true; @@ -196,13 +214,14 @@ private static boolean isInGeneratedFile(Class definition, * than for everything an application declares. */ private static String withMissingEntries(String generated, - Collection> definitions) { + Collection> definitions, boolean withNames) { if (generated == null || generated.isBlank()) { - return renderFileContent(definitions); + return renderFileContent(definitions, withNames); } List missing = definitions.stream() .sorted(Comparator.comparing(Class::getName)) - .flatMap(definition -> renderFunctions(definition).stream()) + .flatMap(definition -> renderFunctions(definition, withNames) + .stream()) .filter(function -> !generated.contains(function)) .flatMap(function -> Arrays .stream(function.split(System.lineSeparator()))) @@ -216,11 +235,15 @@ private static String withMissingEntries(String generated, if (generated.contains(footer)) { return generated.replace(footer, added + separator + footer); } + List header = new ArrayList<>(HEADER); + if (withNames) { + header.add(NAMES); + } // Written by another version of this class: what it holds is what a // browser has, so the functions go after it rather than instead of it. // The header only assigns what is not there, so repeating it is what // makes the content that follows land in the registry. - return generated + separator + String.join(separator, HEADER) + return generated + separator + String.join(separator, header) + separator + added + separator + footer; } @@ -259,8 +282,9 @@ private static Logger getLogger() { * @return the lines this definition contributes, empty if it declares no * JavaScript */ - static List renderDefinitionLines(Class definition) { - return renderFunctions(definition).stream() + static List renderDefinitionLines(Class definition, + boolean withNames) { + return renderFunctions(definition, withNames).stream() .flatMap(function -> Arrays .stream(function.split(System.lineSeparator()))) .toList(); @@ -270,7 +294,8 @@ static List renderDefinitionLines(Class definition) { * What one JavaScript definition contributes, one registered function per * method that declares JavaScript, each as the lines it is written as. */ - private static List renderFunctions(Class definition) { + private static List renderFunctions(Class definition, + boolean withNames) { List methods = new ArrayList<>(); for (Method method : definition.getMethods()) { if (method.isAnnotationPresent(JsExpression.class)) { @@ -290,14 +315,30 @@ private static List renderFunctions(Class definition) { .mapToObj(index -> "$" + index) .reduce((first, second) -> first + ", " + second) .orElse(""); - functions.add(String.join(System.lineSeparator(), String.format( + List function = new ArrayList<>(List.of(String.format( "window.Vaadin.Flow.jsDefinitions[%s] = async function (%s) {", quote(functionId(method)), parameters), method.getAnnotation(JsExpression.class).value(), "};")); + if (withNames) { + function.add(String.format( + "window.Vaadin.Flow.jsDefinitionNames[%s] = %s;", + quote(functionId(method)), + quote(nameOf(definition, method)))); + } + functions.add(String.join(System.lineSeparator(), function)); } return functions; } + /** + * What a message about a call of the given method names it by: what a + * developer wrote, rather than the hash the call itself carries. + */ + private static String nameOf(Class definition, Method method) { + return definition.getName() + "." + method.getName() + "/" + + method.getParameterCount(); + } + private static String functionId(Method method) { return JsCall.functionId( method.getAnnotation(JsExpression.class).value(), diff --git a/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitionsTest.java b/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitionsTest.java index cc714d60565..ce303472819 100644 --- a/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitionsTest.java +++ b/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitionsTest.java @@ -81,7 +81,8 @@ void setUp() { options = new Options(Mockito.mock(Lookup.class), new DefaultClassFinder( Set.of(GreeterJs.class, NothingJs.class)), - null).withFrontendDirectory(frontendFolder); + null).withFrontendDirectory(frontendFolder) + .withProductionMode(true); task = new TaskGenerateJsDefinitions(options); } @@ -108,13 +109,28 @@ void generatesAFunctionPerDeclaredExpression() + content); } + @Test + void generatedFile_developmentMode_namesWhatDeclaredTheJavaScript() { + // Which is what a message about a call says instead of a hash, and is + // of no use to a browser running the application + String content = TaskGenerateJsDefinitions + .renderFileContent(List.of(GreeterJs.class), true); + + assertTrue( + content.contains("window.Vaadin.Flow.jsDefinitionNames[\"" + + JsCall.functionId(GREETING_EXPRESSION, 1) + "\"] = \"" + + GreeterJs.class.getName() + ".showGreeting/1\";"), + "the name should be registered next to the function: " + + content); + } + @Test void generatedFile_namesNothingOfTheJava() throws ExecutionFailedException { task.execute(); String content = task.getFileContent(); assertFalse(content.contains(GreeterJs.class.getName()), - "a browser that loads the file should not be told what declared the JavaScript: " + "a production bundle should not tell a browser what declared the JavaScript: " + content); assertFalse(content.contains("showGreeting"), "and not what the methods are called either: " + content); 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 9292847b84b..71498fc9b9f 100644 --- a/flow-client/src/main/frontend/internal/client/flow/ExecuteJavaScriptProcessor.ts +++ b/flow-client/src/main/frontend/internal/client/flow/ExecuteJavaScriptProcessor.ts @@ -66,27 +66,28 @@ interface ContextCallbacks { disposeInitializer: (node: StateNode, id: number) => void; } -/** - * What an invocation of declared JavaScript ends with instead of an expression: - * the function 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. - * - * The function is identified by a hash of the JavaScript it runs, so a message - * about a call has nothing to name it by. Outside production mode the server - * sends what a developer wrote as `debug`, which is only ever printed. - */ -export interface JsDefinitionTarget { - function: string; - arguments: number; - returns?: boolean; - debug?: string; -} - type JsDefinitionFunction = (this: unknown, ...args: unknown[]) => unknown; +// What the server sends instead of an expression: the identifier of a function +// of the bundle, which is a hash of the JavaScript it runs. Anything else it +// sends is an expression, and one of these is not valid JavaScript, so an +// identifier that the bundle does not have is reported rather than run. +const FUNCTION_ID = /^[0-9a-f]{64}$/u; + type ReturnChannel = (value: unknown) => void; +/** + * Reports the given message through the error channel of an invocation, which + * is its last parameter when it was subscribed to, so that the pending result + * of the call is completed rather than left hanging on the server. + */ +function reportThroughChannel(parameters: unknown[], message: string): void { + const lastParameter = parameters[parameters.length - 1]; + if (typeof lastParameter === 'function') { + (lastParameter as ReturnChannel)(message); + } +} + /** * Looks up the function that the build generated for declared JavaScript. The * registry is populated by the generated bundle, so the function is ordinary @@ -102,11 +103,17 @@ function findDeclaredFunction(functionId: string): JsDefinitionFunction | undefi } /** - * What to call the target in a message: what a developer wrote when the server - * sent it, and the identifier of the function otherwise. + * What to call a function in a message: what a developer wrote, which a + * development bundle registers next to the function itself, and the identifier + * of the function when it does not, as in production. */ -function nameOf(target: JsDefinitionTarget): string { - return target.debug ?? target.function; +function nameOf(functionId: string): string { + const names = ( + window as unknown as { + Vaadin?: { Flow?: { jsDefinitionNames?: Record } }; + } + ).Vaadin?.Flow?.jsDefinitionNames; + return names?.[functionId] ?? functionId; } /** @@ -170,9 +177,7 @@ export class ExecuteJavaScriptProcessor { // What to run is a constant of the message, the same way for an // expression and for a call of declared JavaScript, so an expression the // server runs again costs a reference rather than its own text. - const whatToRun = this.#registry - .getConstantPool() - .get(invocation[invocation.length - 1] as string); + const whatToRun = this.#registry.getConstantPool().get(invocation[invocation.length - 1] as string); if (whatToRun === null) { Console.error( `No constant for the invocation ${JSON.stringify(invocation)}. Reload the page to pick up the current state.` @@ -180,7 +185,7 @@ export class ExecuteJavaScriptProcessor { return; } - if (typeof whatToRun === 'object') { + if (FUNCTION_ID.test(whatToRun)) { // A call of declared JavaScript: the bundle has the function, the // server sent only which one to run. The node parameters are for the // context object an expression runs against, whose `getNode` maps an @@ -263,60 +268,52 @@ export class ExecuteJavaScriptProcessor { } /** - * Executes a call made through a JavaScript definition: 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`. + * Executes a call made through a JavaScript definition: 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 JavaScript definition and method to run + * @param functionId - the identifier of the function 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 + * when the call is subscribed to */ - protected invokeFromBundle(target: JsDefinitionTarget, 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) { - const message = `Expected ${expectedCount} parameters for ${nameOf(target)} but the invocation carries ${ - parameters.length - }. Reload the page to pick up the current signature.`; + protected invokeFromBundle(functionId: string, parameters: unknown[]): void { + const name = nameOf(functionId); + const fn = findDeclaredFunction(functionId); + if (fn === undefined) { + const message = `No JavaScript in the bundle for ${name}. The JavaScript definition is annotated with @JsDefinition, but the build did not collect it.`; 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); - } - } + // of them, so the error channel is the last parameter. Report through it + // when there is one, or the pending result of the call is never + // completed on the server. + reportThroughChannel(parameters, message); return; } - const onSuccess = target.returns === true ? (parameters[argumentCount + 1] as ReturnChannel) : undefined; - const onError = target.returns === true ? (parameters[argumentCount + 2] as ReturnChannel) : undefined; - - const fn = findDeclaredFunction(target.function); - if (fn === undefined) { - const message = `No JavaScript in the bundle for ${nameOf( - target - )}. The JavaScript definition is annotated with @JsDefinition, but the build did not collect it.`; + // The function takes the arguments of the call, so what follows them is + // the element to apply it to, and then the two return value channels when + // the call is subscribed to. Nothing else may be in there, so a count that + // does not add up means the invocation was not built for this function, + // and reading the element out of it by index would bind an argument as + // `this`. Say so instead of running the call. + const argumentCount = fn.length; + const afterTheArguments = parameters.length - argumentCount; + if (afterTheArguments !== 1 && afterTheArguments !== 3) { + const message = `Expected ${argumentCount} arguments and the element for ${name} but the invocation carries ${parameters.length} parameters. Reload the page to pick up the current signature.`; Console.error(message); - onError?.(message); + reportThroughChannel(parameters, message); return; } + const returns = afterTheArguments === 3; + const onSuccess = returns ? (parameters[argumentCount + 1] as ReturnChannel) : undefined; + const onError = returns ? (parameters[argumentCount + 2] as ReturnChannel) : undefined; + // The element the definition was obtained from is the parameter after the // arguments, and it is what the function runs against. const thisArg = parameters[argumentCount]; @@ -327,7 +324,7 @@ export class ExecuteJavaScriptProcessor { } } catch (exception) { Console.reportStacktrace(exception); - Console.error(`Exception is thrown while running ${nameOf(target)}. Stacktrace will be dumped separately.`); + Console.error(`Exception is thrown while running ${name}. 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 b6c9fb73663..cf302030834 100644 --- a/flow-client/src/test/frontend/internal/client/flow/ExecuteJavaScriptProcessorTests.ts +++ b/flow-client/src/test/frontend/internal/client/flow/ExecuteJavaScriptProcessorTests.ts @@ -86,22 +86,31 @@ function registeredNode(registry: TestRegistry, id: number): StateNode { describe('ExecuteJavaScriptProcessor', () => { describe('JavaScript definition calls', () => { - const GREETING = '4e6f2a'; - const VALUE = '9c1b7d'; + // What the server sends: the identifier of a function of the bundle, which + // is a hash of the JavaScript it runs + const GREETING = 'a'.repeat(64); + const VALUE = 'b'.repeat(64); type DefinitionFunction = (this: unknown, ...args: unknown[]) => unknown; type DefinitionWindow = Window & { - Vaadin?: { Flow?: { jsDefinitions?: Record } }; + Vaadin?: { + Flow?: { jsDefinitions?: Record; jsDefinitionNames?: Record }; + }; }; - // Registers a function the way the generated bundle does. - function registerDefinition(functionId: string, fn: DefinitionFunction): void { + // Registers a function the way the generated bundle does, with the name a + // message calls it by, which a development bundle registers with it. + function registerDefinition(functionId: string, fn: DefinitionFunction, name?: string): void { const vaadin = (window as DefinitionWindow).Vaadin ?? {}; (window as DefinitionWindow).Vaadin = vaadin; vaadin.Flow = vaadin.Flow ?? {}; vaadin.Flow.jsDefinitions = vaadin.Flow.jsDefinitions ?? {}; vaadin.Flow.jsDefinitions[functionId] = fn; + if (name !== undefined) { + vaadin.Flow.jsDefinitionNames = vaadin.Flow.jsDefinitionNames ?? {}; + vaadin.Flow.jsDefinitionNames[functionId] = name; + } } function fixture(): { processor: ExecuteJavaScriptProcessor; registry: TestRegistry } { @@ -119,18 +128,21 @@ describe('ExecuteJavaScriptProcessor', () => { } afterEach(() => { - delete (window as DefinitionWindow).Vaadin?.Flow?.jsDefinitions?.[GREETING]; - delete (window as DefinitionWindow).Vaadin?.Flow?.jsDefinitions?.[VALUE]; + const flow = (window as DefinitionWindow).Vaadin?.Flow; + for (const functionId of [GREETING, VALUE]) { + delete flow?.jsDefinitions?.[functionId]; + delete flow?.jsDefinitionNames?.[functionId]; + } }); it('runs the function from the bundle against the element', () => { const calls: Array<{ thisArg: unknown; args: unknown[] }> = []; - registerDefinition(GREETING, function (this: unknown, ...args: unknown[]) { - calls.push({ thisArg: this, args }); + registerDefinition(GREETING, function (this: unknown, greeting: unknown) { + calls.push({ thisArg: this, args: [greeting] }); }); const element = { tagName: 'div' }; - run(['Hello', element, { function: GREETING, arguments: 1 }]); + run(['Hello', element, GREETING]); expect(calls).to.have.lengthOf(1); expect(calls[0].thisArg).to.equal(element); @@ -142,12 +154,7 @@ describe('ExecuteJavaScriptProcessor', () => { const resolved: unknown[] = []; const element = { tagName: 'div' }; - run([ - element, - (value: unknown) => resolved.push(value), - () => {}, - { function: VALUE, arguments: 0, returns: true } - ]); + run([element, (value: unknown) => resolved.push(value), () => {}, VALUE]); // Settled in microtasks: a macrotask wait would also pick up the // asynchronous rethrow that the expression cases leave behind. await Promise.resolve(); @@ -156,54 +163,55 @@ describe('ExecuteJavaScriptProcessor', () => { expect(resolved).to.eql(['answer']); }); - it('does not run a call whose parameters do not match the target', () => { + it('does not run a call whose parameters do not match the function', () => { let calls = 0; - registerDefinition(GREETING, () => { + registerDefinition(GREETING, (_greeting: unknown) => { calls += 1; }); - // One argument declared, but no element to apply the function to: the - // invocation and this client disagree about the signature, which is the + // One argument the function takes, but no element to apply it to: the + // invocation and the bundle disagree about the signature, which is the // same disagreement as an invocation that carries one parameter too // many. - run(['Hello', { function: GREETING, arguments: 1 }]); + run(['Hello', GREETING]); expect(calls).to.equal(0); }); - it('reports a mismatch to the error channel of a call that returns a value', () => { + it('reports a mismatch to the error channel, naming the function as it was written', () => { let calls = 0; - registerDefinition(VALUE, () => { - calls += 1; - return 'answer'; - }); + registerDefinition( + VALUE, + () => { + calls += 1; + return 'answer'; + }, + 'com.acme.GreeterJs.readValue/0' + ); const errors: unknown[] = []; const element = { tagName: 'div' }; - // Subscribed to, but one channel short of what the target declares. - run([element, (error: unknown) => errors.push(error), { function: VALUE, arguments: 0, returns: true }]); + // Subscribed to, but one channel short of what the server sends. + run([element, (error: unknown) => errors.push(error), VALUE]); 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); + // A development bundle registers what a developer wrote next to the + // function, so a message says more than a hash does + expect(String(errors[0])).to.contain('com.acme.GreeterJs.readValue/0'); }); it('reports a function that is not in the bundle to the error channel', () => { const errors: unknown[] = []; const element = { tagName: 'div' }; - run([ - element, - () => {}, - (error: unknown) => errors.push(error), - { function: 'notinthebundle', arguments: 0, returns: true, debug: 'com.acme.GreeterJs.readValue/0' } - ]); + run([element, () => {}, (error: unknown) => errors.push(error), VALUE]); expect(errors).to.have.lengthOf(1); - // What the server sends outside production mode, so that the message - // says more than a hash does - expect(String(errors[0])).to.contain('com.acme.GreeterJs.readValue/0'); + // Nothing registered it, so the message has only the identifier + expect(String(errors[0])).to.contain(VALUE); }); }); diff --git a/flow-server/src/main/java/com/vaadin/flow/js/JsCall.java b/flow-server/src/main/java/com/vaadin/flow/js/JsCall.java index 4bce72a52f4..0da75e0f97e 100644 --- a/flow-server/src/main/java/com/vaadin/flow/js/JsCall.java +++ b/flow-server/src/main/java/com/vaadin/flow/js/JsCall.java @@ -99,18 +99,6 @@ public static String functionId(String expression, int argumentCount) { StandardCharsets.UTF_8); } - /** - * Describes this call the way a developer wrote it, for a message about it - * that a hash would make unreadable. - * - * @return the interface, the method and the number of arguments, not - * null - */ - public String getDebugInfo() { - return definitionType.getName() + "." + methodName + "/" - + arguments.size(); - } - /** * Gets the JavaScript that this call runs in a browser, as declared by * {@link JsExpression} on the called method. 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 218322c0a13..5cf3ebe5df2 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 @@ -180,9 +180,7 @@ public ObjectNode createUidl(UI ui, boolean async, boolean resync) { if (!executeJavaScriptList.isEmpty()) { response.set(JsonConstants.UIDL_KEY_EXECUTE, encodeExecuteJavaScriptList(executeJavaScriptList, - uiInternals.getConstantPool(), - !service.getDeploymentConfiguration() - .isProductionMode())); + uiInternals.getConstantPool())); } // Dumped after the invocations are encoded, since what each of them // runs is a constant of this response @@ -315,10 +313,9 @@ private static InputStream getInlineResourceStream(String url, // non-private for testing purposes static ArrayNode encodeExecuteJavaScriptList( List executeJavaScriptList, - ConstantPool constantPool, boolean withDebugInfo) { - return executeJavaScriptList.stream() - .map(invocation -> encodeExecuteJavaScript(invocation, - constantPool, withDebugInfo)) + ConstantPool constantPool) { + return executeJavaScriptList.stream().map( + invocation -> encodeExecuteJavaScript(invocation, constantPool)) .collect(JacksonUtils.asArray()); } @@ -339,12 +336,10 @@ private static ReturnChannelRegistration createReturnValueChannel( } private static ArrayNode encodeExecuteJavaScript( - PendingJavaScriptInvocation invocation, ConstantPool constantPool, - boolean withDebugInfo) { + PendingJavaScriptInvocation invocation, ConstantPool constantPool) { JsCall jsCall = invocation.getInvocation().getJsCall(); if (jsCall != null) { - return encodeJsCall(invocation, jsCall, constantPool, - withDebugInfo); + return encodeJsCall(invocation, jsCall, constantPool); } List parametersList = invocation.getInvocation() @@ -415,37 +410,24 @@ private static JsonNode constantOf(JsonNode whatToRun, /** * Encodes a call made through a JavaScript definition as - * [argument1, ..., element, successChannel, errorChannel, target], - * where the trailing target object names the function to run instead of - * carrying JavaScript. The function is identified by a hash of the - * JavaScript it runs, so no expression is sent, nothing is compiled in the - * browser, and what declared the JavaScript in Java stays on the server. - *

- * Outside production mode the target also carries what a developer wrote, - * so that a message about the call can name it. The client only prints it. + * [argument1, ..., element, successChannel, errorChannel, function], + * where the trailing constant names the function to run rather than + * carrying JavaScript. The name is a hash of the JavaScript the function + * runs, so no expression is sent, nothing is compiled in the browser, and + * what declared the JavaScript in Java stays on the server. *

- * 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. + * The parameters are the arguments of the call, then the element to apply + * the function to, and the two return value channels when the call is + * subscribed to. The client reads them the other way around: the function + * it looks up takes the arguments, so what follows them is the element and + * the channels, if any. */ private static ArrayNode encodeJsCall( PendingJavaScriptInvocation invocation, JsCall call, - ConstantPool constantPool, boolean withDebugInfo) { + ConstantPool constantPool) { Stream parameters = invocation.getInvocation().getParameters() .stream(); - ObjectNode target = JacksonUtils.createObjectNode(); - target.put(JsonConstants.UIDL_KEY_JS_FUNCTION, - JsCall.functionId(invocation.getInvocation().getExpression(), - call.arguments().size())); - target.put(JsonConstants.UIDL_KEY_JS_FUNCTION_ARGUMENTS, - call.arguments().size()); - if (withDebugInfo) { - target.put(JsonConstants.UIDL_KEY_JS_FUNCTION_DEBUG, - call.getDebugInfo()); - } - if (invocation.isSubscribed()) { StateNode owner = invocation.getOwner(); List channels = new ArrayList<>(); @@ -457,12 +439,16 @@ private static ArrayNode encodeJsCall( parameters = Stream.concat(parameters, Stream.of(successChannel, errorChannel)); - target.put(JsonConstants.UIDL_KEY_JS_FUNCTION_RETURNS, true); } return Stream .concat(parameters.map(JacksonCodec::encodeWithTypeInfo), - Stream.of(constantOf(target, constantPool))) + Stream.of(constantOf( + JacksonUtils.createNode(JsCall.functionId( + invocation.getInvocation() + .getExpression(), + call.arguments().size())), + constantPool))) .collect(JacksonUtils.asArray()); } 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 e33f82234d8..599e69317d6 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,35 +165,6 @@ public class JsonConstants implements Serializable { */ public static final String UIDL_KEY_EXECUTE = "execute"; - /** - * Key of the function to run in the target object that ends an invocation - * of declared JavaScript in UIDL messages, in place of a JavaScript - * expression. The value identifies the JavaScript that the bundle holds, - * and says nothing about the Java that declared it. - */ - public static final String UIDL_KEY_JS_FUNCTION = "function"; - - /** - * Key of the number of leading parameters that are the arguments of an - * invocation of declared JavaScript. The parameter after them is the - * element to apply the function to. - */ - public static final String UIDL_KEY_JS_FUNCTION_ARGUMENTS = "arguments"; - - /** - * Key that marks an invocation of declared JavaScript whose two last - * parameters are the channels for its return value. - */ - public static final String UIDL_KEY_JS_FUNCTION_RETURNS = "returns"; - - /** - * Key of what the client names the function in a message, sent outside - * production mode so that a message about a call can be read. Nothing reads - * it, so a production browser is not told what declared the JavaScript it - * runs. - */ - public static final String UIDL_KEY_JS_FUNCTION_DEBUG = "debug"; - /** * Key used to hold the feature id when synchronizing node values. */ 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 033ca2c1ed3..d2d91340dd0 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 @@ -31,7 +31,6 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.parallel.Isolated; import org.mockito.Mockito; -import tools.jackson.databind.JsonNode; import tools.jackson.databind.node.ArrayNode; import tools.jackson.databind.node.ObjectNode; @@ -193,7 +192,7 @@ void testEncodeExecuteJavaScript_npmMode() { ConstantPool constantPool = new ConstantPool(); ArrayNode json = UidlWriter.encodeExecuteJavaScriptList( - executeJavaScriptList, constantPool, false); + executeJavaScriptList, constantPool); ObjectNode constants = constantPool.dumpConstants(); ArrayNode expectedJson = JacksonUtils.createArray( @@ -214,15 +213,14 @@ void testEncodeExecuteJavaScript_npmMode() { } /** - * What names the given script among the given constants, which is what an - * invocation that runs it carries instead of the script itself. + * What names the given script, or the given function, among the given + * constants, which is what an invocation that runs it carries instead of + * the script or the function itself. */ - private static String nameOfWhatRuns(Object whatRuns, + private static String nameOfWhatRuns(String whatRuns, ObjectNode constants) { return JacksonUtils.getKeys(constants).stream() - .filter(key -> whatRuns instanceof JsonNode node - ? JacksonUtils.jsonEquals(node, constants.get(key)) - : whatRuns.equals(constants.get(key).asString())) + .filter(key -> whatRuns.equals(constants.get(key).asString())) .findFirst() .orElseThrow(() -> new AssertionError("The constants " + constants + " should carry " + whatRuns)); @@ -236,12 +234,12 @@ void encodeExecuteJavaScript_sameScriptTwice_sentOnceAndNamedTwice() { ArrayNode first = UidlWriter.encodeExecuteJavaScriptList( List.of(new PendingJavaScriptInvocation(element.getNode(), new JavaScriptInvocation("$0.focus()", element))), - constantPool, false); + constantPool); constantPool.dumpConstants(); ArrayNode second = UidlWriter.encodeExecuteJavaScriptList( List.of(new PendingJavaScriptInvocation(element.getNode(), new JavaScriptInvocation("$0.focus()", element))), - constantPool, false); + constantPool); assertEquals(nameOfWhatRuns(first), nameOfWhatRuns(second), "the same script should be named the same way"); @@ -268,60 +266,24 @@ void encodeExecuteJavaScript_jsCall_sendsTheTargetInsteadOfTheScript() { ConstantPool constantPool = new ConstantPool(); ArrayNode json = UidlWriter.encodeExecuteJavaScriptList(List.of( new PendingJavaScriptInvocation(element.getNode(), invocation)), - constantPool, false); + constantPool); ObjectNode constants = constantPool.dumpConstants(); - ObjectNode target = JacksonUtils.createObjectNode(); - target.put("function", JsCall.functionId("this.method($0)", 1)); - target.put("arguments", 1); + String functionId = JsCall.functionId("this.method($0)", 1); ArrayNode expectedJson = JacksonUtils.createArray( JacksonUtils.createArray(JacksonUtils.createNode("foo"), // Null since element is not attached JacksonUtils.nullNode(), JacksonUtils.createNode( - nameOfWhatRuns(target, constants)))); + nameOfWhatRuns(functionId, constants)))); assertTrue(JacksonUtils.jsonEquals(expectedJson, json), - "a call of declared JavaScript should name a target, the same way an expression names a script: " + "a call of declared JavaScript should name a function, the same way an expression names a script: " + json + " " + constants); assertFalse(constants.toString().contains(TestJs.class.getName()), - "and the target should carry neither JavaScript nor what declared it: " + "and the constant should carry neither JavaScript nor what declared it: " + constants); } - @Test - void encodeExecuteJavaScript_jsCallOutsideProductionMode_addsWhatToCallIt() { - Element element = ElementFactory.createDiv(); - - JsCall call = new JsCall(TestJs.class, "method", List.of("foo")); - JavaScriptInvocation invocation = new JavaScriptInvocation(call, - call.getExpression(), "foo", element); - - ConstantPool constantPool = new ConstantPool(); - UidlWriter.encodeExecuteJavaScriptList(List.of( - new PendingJavaScriptInvocation(element.getNode(), invocation)), - constantPool, true); - ObjectNode constants = constantPool.dumpConstants(); - - assertEquals(TestJs.class.getName() + ".method/1", - targetIn(constants).get("debug").asString(), - "a message about the call should be able to name it: " - + constants); - } - - /** - * The one target among the given constants, which is what a call of - * declared JavaScript runs. - */ - private static ObjectNode targetIn(ObjectNode constants) { - return (ObjectNode) JacksonUtils.getKeys(constants).stream() - .map(constants::get) - .filter(constant -> constant.isObject() - && constant.has("function")) - .findFirst() - .orElseThrow(() -> new AssertionError("The constants " - + constants + " should carry a target")); - } - @Test void encodeExecuteJavaScript_subscribedDefinitionCall_addsTheReturnChannels() { Element element = ElementFactory.createDiv(); @@ -335,45 +297,19 @@ void encodeExecuteJavaScript_subscribedDefinitionCall_addsTheReturnChannels() { }); ConstantPool constantPool = new ConstantPool(); - ArrayNode json = UidlWriter.encodeExecuteJavaScriptList( - List.of(pending), constantPool, false); + ArrayNode json = UidlWriter + .encodeExecuteJavaScriptList(List.of(pending), constantPool); 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: " + "the argument and the element should be followed by the two channels and the function to run: " + + encoded); + assertEquals( + nameOfWhatRuns(JsCall.functionId("this.method($0)", 1), + constantPool.dumpConstants()), + encoded.get(4).asString(), + "and the function should be the same one as for a call that is not subscribed to: " + encoded); - ObjectNode target = targetIn(constantPool.dumpConstants()); - assertTrue(target.get("returns").asBoolean(), - "the target should tell the client that the call is subscribed to"); - assertEquals(1, target.get("arguments").asInt()); - } - - @Test - void createUidl_productionMode_decidesWhatTheBrowserIsToldAboutACall() - throws Exception { - assertFalse(callInUidl(true).has("debug"), - "a production browser should not be told what declared the JavaScript it runs"); - assertTrue(callInUidl(false).has("debug"), - "and a development one should, or a message about a call can only name a hash"); - } - - /** - * The target of a call of declared JavaScript, as a response written for a - * UI of an application running in the given mode carries it. - */ - private ObjectNode callInUidl(boolean productionMode) throws Exception { - UI ui = initializeUIForDependenciesTest(new UI()); - mocks.getDeploymentConfiguration().setProductionMode(productionMode); - Element element = ElementFactory.createDiv(); - ui.getElement().appendChild(element); - element.executeJs(TestJs.class).method("foo"); - - ObjectNode response = new UidlWriter().createUidl(ui, false); - // An invocation names what it runs among the constants of the - // response; whatever else it carries runs an expression, which is a - // string where a call of declared JavaScript has its target - ObjectNode constants = (ObjectNode) response.get("constants"); - return targetIn(constants); } @JsDefinition From 3830cb981913c452faa216c3dbdc4f706cfa4bf0 Mon Sep 17 00:00:00 2001 From: "totally-not-ai[bot]" <290682512+totally-not-ai[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 07:04:31 +0000 Subject: [PATCH 49/57] fix: add the registry a name is assigned into when a file has none Writing the file again while the application runs put the names of the functions it adds in front of what closes the file, which throws when the file was written before names were rendered at all - a build made by an earlier version of this, or a production build read in development. The registry is added with them now, and both places that open a file build the opening the same way. The cases for writing the file again ran as a production build, which is the one mode that path never runs in, so they run as a development build too: what is added carries its names, the registry that holds them comes first, and a file that already carries everything is left alone. On the client, the two registries are read through one accessor rather than through the same cast written twice. --- .../frontend/TaskGenerateJsDefinitions.java | 38 ++++++--- .../TaskGenerateJsDefinitionsTest.java | 79 ++++++++++++++++--- .../client/flow/ExecuteJavaScriptProcessor.ts | 34 +++++--- 3 files changed, 117 insertions(+), 34 deletions(-) diff --git a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitions.java b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitions.java index 6fd864d60a1..b0280b8ed76 100644 --- a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitions.java +++ b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitions.java @@ -105,10 +105,7 @@ protected String getFileContent() { */ static String renderFileContent(Collection> definitions, boolean withNames) { - List lines = new ArrayList<>(HEADER); - if (withNames) { - lines.add(NAMES); - } + List lines = new ArrayList<>(header(withNames)); definitions.stream().sorted(Comparator.comparing(Class::getName)) .forEach(definition -> lines .addAll(renderDefinitionLines(definition, withNames))); @@ -231,20 +228,37 @@ private static String withMissingEntries(String generated, } String separator = System.lineSeparator(); String footer = String.join(separator, FOOTER); - String added = String.join(separator, missing); - if (generated.contains(footer)) { - return generated.replace(footer, added + separator + footer); + List added = new ArrayList<>(); + if (withNames && !generated.contains(NAMES)) { + // Written before names were registered at all, and a name is + // assigned into an object that has to be there + added.add(NAMES); } - List header = new ArrayList<>(HEADER); - if (withNames) { - header.add(NAMES); + added.addAll(missing); + if (generated.contains(footer)) { + return generated.replace(footer, + String.join(separator, added) + separator + footer); } // Written by another version of this class: what it holds is what a // browser has, so the functions go after it rather than instead of it. // The header only assigns what is not there, so repeating it is what // makes the content that follows land in the registry. - return generated + separator + String.join(separator, header) - + separator + added + separator + footer; + return generated + separator + String.join(separator, header(withNames)) + + separator + String.join(separator, missing) + separator + + footer; + } + + /** + * What a generated file opens with: the registry a function is assigned + * into, and the one a name is assigned into when names are rendered. Both + * assign only what is not there, so a file can carry them more than once. + */ + private static List header(boolean withNames) { + List header = new ArrayList<>(HEADER); + if (withNames) { + header.add(NAMES); + } + return header; } private static String readGeneratedFile(Options options) { diff --git a/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitionsTest.java b/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitionsTest.java index ce303472819..05134ee9cd4 100644 --- a/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitionsTest.java +++ b/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitionsTest.java @@ -47,6 +47,10 @@ class TaskGenerateJsDefinitionsTest { private static final String GREETING_EXPRESSION = "window.alert({ text: $0, kind: 'greeting' })"; + private static final String COUNT_EXPRESSION = "this.count = ($0 || 0) + 1"; + + private static final String NAMES_REGISTRY = "window.Vaadin.Flow.jsDefinitionNames = window.Vaadin.Flow.jsDefinitionNames || {};"; + @JsDefinition public interface GreeterJs extends Serializable { @JsExpression(GREETING_EXPRESSION) @@ -58,7 +62,7 @@ public interface GreeterJs extends Serializable { @JsDefinition public interface CounterJs extends Serializable { - @JsExpression("this.count = ($0 || 0) + 1") + @JsExpression(COUNT_EXPRESSION) void count(Integer from); } @@ -116,14 +120,73 @@ void generatedFile_developmentMode_namesWhatDeclaredTheJavaScript() { String content = TaskGenerateJsDefinitions .renderFileContent(List.of(GreeterJs.class), true); + assertTrue(content.contains(NAMES_REGISTRY), + "the registry a name is assigned into has to be there, or the module throws: " + + content); assertTrue( content.contains("window.Vaadin.Flow.jsDefinitionNames[\"" + JsCall.functionId(GREETING_EXPRESSION, 1) + "\"] = \"" + GreeterJs.class.getName() + ".showGreeting/1\";"), - "the name should be registered next to the function: " + "and the name should be registered next to the function: " + content); } + @Test + void updateJsDefinitions_developmentMode_writesTheNamesAndWhatHoldsThem() + throws IOException { + // What the hotswapper does, which only runs outside production mode, + // into a file that a build wrote before names were rendered at all + Options development = options.withProductionMode(false); + File generated = new File( + FrontendUtils.getFrontendGeneratedFolder(frontendFolder), + FrontendUtils.JS_DEFINITIONS_FILE_NAME); + generated.getParentFile().mkdirs(); + Files.writeString(generated.toPath(), TaskGenerateJsDefinitions + .renderFileContent(List.of(GreeterJs.class), false)); + + List> missing = TaskGenerateJsDefinitions + .updateJsDefinitions(development, List.of(CounterJs.class)); + + assertTrue(missing.isEmpty()); + String written = Files.readString(generated.toPath()); + assertTrue(written.contains(NAMES_REGISTRY), + "the registry the names are assigned into should be added to a file that has none: " + + written); + assertTrue( + written.indexOf(NAMES_REGISTRY) < written + .indexOf("window.Vaadin.Flow.jsDefinitionNames[\""), + "and it should come before the name it holds: " + written); + assertTrue( + written.contains("window.Vaadin.Flow.jsDefinitionNames[\"" + + JsCall.functionId(COUNT_EXPRESSION, 1) + "\"] = \"" + + CounterJs.class.getName() + ".count/1\";"), + "the name of what was asked for should be in the file: " + + written); + assertTrue(written.contains(JsCall.functionId(GREETING_EXPRESSION, 1)), + "and what the file held should still be in it: " + written); + } + + @Test + void updateJsDefinitions_developmentMode_nothingMissing_leavesTheFileAlone() + throws IOException { + // The same file a dev build writes: everything it is asked for is in + // it, names and all, so there is nothing to add + Options development = options.withProductionMode(false); + File generated = new File( + FrontendUtils.getFrontendGeneratedFolder(frontendFolder), + FrontendUtils.JS_DEFINITIONS_FILE_NAME); + generated.getParentFile().mkdirs(); + String carried = TaskGenerateJsDefinitions + .renderFileContent(List.of(GreeterJs.class), true); + Files.writeString(generated.toPath(), carried); + + TaskGenerateJsDefinitions.updateJsDefinitions(development, + List.of(GreeterJs.class)); + + assertEquals(carried, Files.readString(generated.toPath()), + "a definition the file carries should not be written into it again"); + } + @Test void generatedFile_namesNothingOfTheJava() throws ExecutionFailedException { task.execute(); @@ -218,16 +281,14 @@ void updateJsDefinitions_writesWhatIsAskedForBesideWhatTheFileHolds() assertTrue(missing.isEmpty()); String written = Files.readString(generated.toPath()); - assertTrue( - written.contains( - JsCall.functionId("this.count = ($0 || 0) + 1", 1)), + assertTrue(written.contains(JsCall.functionId(COUNT_EXPRESSION, 1)), "the interface that was asked for should be in the file: " + written); assertTrue(written.contains(JsCall.functionId(GREETING_EXPRESSION, 1)), "what the file held should still be in it: " + written); assertTrue( - written.indexOf("import.meta.hot") > written.indexOf( - JsCall.functionId("this.count = ($0 || 0) + 1", 1)), + written.indexOf("import.meta.hot") > written + .indexOf(JsCall.functionId(COUNT_EXPRESSION, 1)), "and what was added should be part of the module: " + written); } @@ -252,9 +313,7 @@ void updateJsDefinitions_fileWrittenByAnotherVersion_keepsWhatItHolds() assertTrue(written.contains("fromsomewhereelse"), "a function a browser has should not be taken out of the file: " + written); - assertTrue( - written.contains( - JsCall.functionId("this.count = ($0 || 0) + 1", 1)), + assertTrue(written.contains(JsCall.functionId(COUNT_EXPRESSION, 1)), "and the one that was asked for should be in it: " + written); } 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 71498fc9b9f..230cabe3099 100644 --- a/flow-client/src/main/frontend/internal/client/flow/ExecuteJavaScriptProcessor.ts +++ b/flow-client/src/main/frontend/internal/client/flow/ExecuteJavaScriptProcessor.ts @@ -88,18 +88,33 @@ function reportThroughChannel(parameters: unknown[], message: string): void { } } +/** + * What the generated bundle registers on the page: a function per declared + * expression, and, outside production, what a developer wrote for each of + * them. + */ +function declaredJavaScript(): { + jsDefinitions?: Record; + jsDefinitionNames?: Record; +} { + return ( + ( + window as unknown as { + Vaadin?: { + Flow?: { jsDefinitions?: Record; jsDefinitionNames?: Record }; + }; + } + ).Vaadin?.Flow ?? {} + ); +} + /** * Looks up the function that the build generated for declared JavaScript. 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 findDeclaredFunction(functionId: string): JsDefinitionFunction | undefined { - const registry = ( - window as unknown as { - Vaadin?: { Flow?: { jsDefinitions?: Record } }; - } - ).Vaadin?.Flow?.jsDefinitions; - return registry?.[functionId]; + return declaredJavaScript().jsDefinitions?.[functionId]; } /** @@ -108,12 +123,7 @@ function findDeclaredFunction(functionId: string): JsDefinitionFunction | undefi * of the function when it does not, as in production. */ function nameOf(functionId: string): string { - const names = ( - window as unknown as { - Vaadin?: { Flow?: { jsDefinitionNames?: Record } }; - } - ).Vaadin?.Flow?.jsDefinitionNames; - return names?.[functionId] ?? functionId; + return declaredJavaScript().jsDefinitionNames?.[functionId] ?? functionId; } /** From a7ff87c27de217ad09a758e817f89db255074991 Mon Sep 17 00:00:00 2001 From: "totally-not-ai[bot]" <290682512+totally-not-ai[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 10:48:28 +0000 Subject: [PATCH 50/57] refactor: read what an invocation runs out of the pool, and say what runs when The constants of a message go into the pool as it arrives, before anything decides what to do with the message, since what an invocation of it runs is read from there - which is what tells a forced reload apart from any other invocation while a resynchronization is ongoing. A message that is queued is read again when it is handled, and a key is a hash of its value, so the pool takes the same key again. Writing the generated file while the application runs is only ever a development build, so it no longer asks which mode it is in, and the cases for it run in that mode too. Whether a build writes the names stays where the build decides it. Names with verbs, in the places this change put them: renderHeader, resolveWhatRuns, getDeclaredJavaScript and getNameOf. And the generated file is written whenever the frontend is generated, rather than asking whether there is a class finder, which a build always has. --- .../frontend/TaskGenerateJsDefinitions.java | 46 +++++++++--------- .../TaskGenerateJsDefinitionsTest.java | 35 +++++++------- .../client/communication/MessageHandler.ts | 35 +++++++------- .../internal/client/flow/ConstantPool.ts | 7 ++- .../client/flow/ExecuteJavaScriptProcessor.ts | 10 ++-- .../communication/MessageHandlerTests.ts | 48 +++++++++++++++---- .../internal/client/flow/ConstantPoolTests.ts | 9 ++++ 7 files changed, 115 insertions(+), 75 deletions(-) diff --git a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitions.java b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitions.java index b0280b8ed76..fd5d46598d8 100644 --- a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitions.java +++ b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitions.java @@ -105,7 +105,7 @@ protected String getFileContent() { */ static String renderFileContent(Collection> definitions, boolean withNames) { - List lines = new ArrayList<>(header(withNames)); + List lines = new ArrayList<>(renderHeader(withNames)); definitions.stream().sorted(Comparator.comparing(Class::getName)) .forEach(definition -> lines .addAll(renderDefinitionLines(definition, withNames))); @@ -128,8 +128,7 @@ public static List> findMissingFromGeneratedFile(Options options, Collection> definitions) { String generated = readGeneratedFile(options); return definitions.stream() - .filter(definition -> !isInGeneratedFile(definition, generated, - !options.isProductionMode())) + .filter(definition -> !isInGeneratedFile(definition, generated)) .toList(); } @@ -159,8 +158,7 @@ public static List> findMissingFromGeneratedFile(Options options, public static List> updateJsDefinitions(Options options, Collection> definitions) { String generated = readGeneratedFile(options); - String content = withMissingEntries(generated, definitions, - !options.isProductionMode()); + String content = withMissingEntries(generated, definitions); TaskGenerateJsDefinitions task = new TaskGenerateJsDefinitions(options); try { @@ -169,9 +167,8 @@ public static List> updateJsDefinitions(Options options, getLogger().debug("Could not write {}", task.getGeneratedFile(), e); // The file is as it was, so only what it was already missing is // missing now - return definitions.stream() - .filter(definition -> !isInGeneratedFile(definition, - generated, !options.isProductionMode())) + return definitions.stream().filter( + definition -> !isInGeneratedFile(definition, generated)) .toList(); } // Everything asked for went into the content that was written @@ -180,17 +177,18 @@ public static List> updateJsDefinitions(Options options, /** * Whether the given content carries what the definition declares, compared - * as this class renders it, so the JavaScript of every method and the - * number of arguments it takes 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. + * as this class renders it for a development build - the one the caller of + * this runs in - so the JavaScript of every method, the number of arguments + * it takes and its name 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 definition, - String generated, boolean withNames) { + String generated) { if (generated == null) { return false; } - List declared = renderDefinitionLines(definition, withNames); + List declared = renderDefinitionLines(definition, true); if (declared.isEmpty()) { // Declares no JavaScript, so there is nothing to carry return true; @@ -211,13 +209,13 @@ private static boolean isInGeneratedFile(Class definition, * than for everything an application declares. */ private static String withMissingEntries(String generated, - Collection> definitions, boolean withNames) { + Collection> definitions) { if (generated == null || generated.isBlank()) { - return renderFileContent(definitions, withNames); + return renderFileContent(definitions, true); } List missing = definitions.stream() .sorted(Comparator.comparing(Class::getName)) - .flatMap(definition -> renderFunctions(definition, withNames) + .flatMap(definition -> renderFunctions(definition, true) .stream()) .filter(function -> !generated.contains(function)) .flatMap(function -> Arrays @@ -229,7 +227,7 @@ private static String withMissingEntries(String generated, String separator = System.lineSeparator(); String footer = String.join(separator, FOOTER); List added = new ArrayList<>(); - if (withNames && !generated.contains(NAMES)) { + if (!generated.contains(NAMES)) { // Written before names were registered at all, and a name is // assigned into an object that has to be there added.add(NAMES); @@ -243,9 +241,9 @@ private static String withMissingEntries(String generated, // browser has, so the functions go after it rather than instead of it. // The header only assigns what is not there, so repeating it is what // makes the content that follows land in the registry. - return generated + separator + String.join(separator, header(withNames)) - + separator + String.join(separator, missing) + separator - + footer; + return generated + separator + + String.join(separator, renderHeader(true)) + separator + + String.join(separator, missing) + separator + footer; } /** @@ -253,7 +251,7 @@ private static String withMissingEntries(String generated, * into, and the one a name is assigned into when names are rendered. Both * assign only what is not there, so a file can carry them more than once. */ - private static List header(boolean withNames) { + private static List renderHeader(boolean withNames) { List header = new ArrayList<>(HEADER); if (withNames) { header.add(NAMES); @@ -372,6 +370,8 @@ protected File getGeneratedFile() { @Override protected boolean shouldGenerate() { - return options.getClassFinder() != null; + // Whether an application declares any JavaScript is answered by + // scanning for it, which a build that scans for anything can do + return true; } } diff --git a/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitionsTest.java b/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitionsTest.java index 05134ee9cd4..411f27e2b14 100644 --- a/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitionsTest.java +++ b/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitionsTest.java @@ -86,7 +86,7 @@ void setUp() { new DefaultClassFinder( Set.of(GreeterJs.class, NothingJs.class)), null).withFrontendDirectory(frontendFolder) - .withProductionMode(true); + .withProductionMode(false); task = new TaskGenerateJsDefinitions(options); } @@ -114,11 +114,12 @@ void generatesAFunctionPerDeclaredExpression() } @Test - void generatedFile_developmentMode_namesWhatDeclaredTheJavaScript() { + void generatedFile_developmentMode_namesWhatDeclaredTheJavaScript() + throws ExecutionFailedException { // Which is what a message about a call says instead of a hash, and is // of no use to a browser running the application - String content = TaskGenerateJsDefinitions - .renderFileContent(List.of(GreeterJs.class), true); + task.execute(); + String content = task.getFileContent(); assertTrue(content.contains(NAMES_REGISTRY), "the registry a name is assigned into has to be there, or the module throws: " @@ -132,11 +133,9 @@ void generatedFile_developmentMode_namesWhatDeclaredTheJavaScript() { } @Test - void updateJsDefinitions_developmentMode_writesTheNamesAndWhatHoldsThem() + void updateJsDefinitions_writesTheNamesAndWhatHoldsThem() throws IOException { - // What the hotswapper does, which only runs outside production mode, - // into a file that a build wrote before names were rendered at all - Options development = options.withProductionMode(false); + // Into a file that a build wrote before names were rendered at all File generated = new File( FrontendUtils.getFrontendGeneratedFolder(frontendFolder), FrontendUtils.JS_DEFINITIONS_FILE_NAME); @@ -145,7 +144,7 @@ void updateJsDefinitions_developmentMode_writesTheNamesAndWhatHoldsThem() .renderFileContent(List.of(GreeterJs.class), false)); List> missing = TaskGenerateJsDefinitions - .updateJsDefinitions(development, List.of(CounterJs.class)); + .updateJsDefinitions(options, List.of(CounterJs.class)); assertTrue(missing.isEmpty()); String written = Files.readString(generated.toPath()); @@ -167,11 +166,10 @@ void updateJsDefinitions_developmentMode_writesTheNamesAndWhatHoldsThem() } @Test - void updateJsDefinitions_developmentMode_nothingMissing_leavesTheFileAlone() + void updateJsDefinitions_nothingMissing_leavesTheFileAlone() throws IOException { - // The same file a dev build writes: everything it is asked for is in - // it, names and all, so there is nothing to add - Options development = options.withProductionMode(false); + // The same file a build writes: everything it is asked for is in it, + // names and all, so there is nothing to add File generated = new File( FrontendUtils.getFrontendGeneratedFolder(frontendFolder), FrontendUtils.JS_DEFINITIONS_FILE_NAME); @@ -180,7 +178,7 @@ void updateJsDefinitions_developmentMode_nothingMissing_leavesTheFileAlone() .renderFileContent(List.of(GreeterJs.class), true); Files.writeString(generated.toPath(), carried); - TaskGenerateJsDefinitions.updateJsDefinitions(development, + TaskGenerateJsDefinitions.updateJsDefinitions(options, List.of(GreeterJs.class)); assertEquals(carried, Files.readString(generated.toPath()), @@ -188,9 +186,9 @@ void updateJsDefinitions_developmentMode_nothingMissing_leavesTheFileAlone() } @Test - void generatedFile_namesNothingOfTheJava() throws ExecutionFailedException { - task.execute(); - String content = task.getFileContent(); + void generatedFile_productionMode_namesNothingOfTheJava() { + String content = new TaskGenerateJsDefinitions( + options.withProductionMode(true)).getFileContent(); assertFalse(content.contains(GreeterJs.class.getName()), "a production bundle should not tell a browser what declared the JavaScript: " @@ -326,7 +324,8 @@ void updateJsDefinitions_oneMethodEdited_writesOnlyThatFunction() File generated = new File( FrontendUtils.getFrontendGeneratedFolder(frontendFolder), FrontendUtils.JS_DEFINITIONS_FILE_NAME); - String unchanged = JsCall.functionId("window.alert('Hello')", 0); + String unchanged = "window.Vaadin.Flow.jsDefinitions[\"" + + JsCall.functionId("window.alert('Hello')", 0) + "\"]"; Files.writeString(generated.toPath(), Files.readString(generated.toPath()).replace( GREETING_EXPRESSION, "window.alert('what it was')")); diff --git a/flow-client/src/main/frontend/internal/client/communication/MessageHandler.ts b/flow-client/src/main/frontend/internal/client/communication/MessageHandler.ts index e2fdf15da7f..93e2d6598a6 100644 --- a/flow-client/src/main/frontend/internal/client/communication/MessageHandler.ts +++ b/flow-client/src/main/frontend/internal/client/communication/MessageHandler.ts @@ -182,6 +182,15 @@ export class MessageHandler { const serverId = getServerId(valueMap); const hasResynchronize = isResynchronize(valueMap); + // Before anything decides what to do with the message, since what an + // invocation of it runs is a constant of it, and that decides whether a + // forced reload is what arrived. A message that is queued here is read + // again when it is handled, and a key is a hash of its value, so the + // second import is the same values. + if ('constants' in valueMap) { + this.#registry.getConstantPool().importFromJson(valueMap.constants as Record); + } + if ( !hasResynchronize && this.#registry.getMessageSender().getResynchronizationState() === ResynchronizationState.WAITING_FOR_RESPONSE @@ -189,12 +198,7 @@ export class MessageHandler { if (UIDL_KEY_EXECUTE in valueMap) { const commands = valueMap[UIDL_KEY_EXECUTE] as unknown[][]; for (const command of commands) { - const runs = whatInvocationRuns( - command, - (valueMap.constants ?? {}) as Record, - this.#registry.getConstantPool() - ); - if (runs === 'window.location.reload();') { + if (resolveWhatRuns(command, this.#registry.getConstantPool()) === 'window.location.reload();') { Console.warn('Executing forced page reload while a resync request is ongoing.'); window.location.reload(); return; @@ -344,9 +348,8 @@ export class MessageHandler { } try { const processUidlStart = performance.now(); - if ('constants' in valueMap) { - this.#registry.getConstantPool().importFromJson(valueMap.constants as Record); - } + // The constants went into the pool as the message arrived, which is + // before anything reads what one of its invocations runs if ('changes' in valueMap) { this.#processChanges(valueMap); } @@ -664,23 +667,19 @@ export class MessageHandler { * @returns A ValueMap created from the JSON */ /** - * What an invocation runs, which the invocation names rather than carries. + * Resolves what an invocation runs, which the invocation names rather than + * carries. * * @param invocation - the invocation, whose last element is the name - * @param constants - the constants of the message carrying the invocation - * @param constantPool - what earlier messages put in the pool + * @param constantPool - the constants the client has been sent * @returns what to run, or `null` when nothing is named */ -export function whatInvocationRuns( - invocation: unknown[], - constants: Record, - constantPool: ConstantPool -): unknown { +export function resolveWhatRuns(invocation: unknown[], constantPool: ConstantPool): unknown { const name = invocation[invocation.length - 1]; if (typeof name !== 'string') { return null; } - return constants[name] ?? constantPool.get(name); + return constantPool.get(name); } export function parseJson(jsonText: string | null): ValueMap | null { diff --git a/flow-client/src/main/frontend/internal/client/flow/ConstantPool.ts b/flow-client/src/main/frontend/internal/client/flow/ConstantPool.ts index 5bd3917fdc7..de012d64c3d 100644 --- a/flow-client/src/main/frontend/internal/client/flow/ConstantPool.ts +++ b/flow-client/src/main/frontend/internal/client/flow/ConstantPool.ts @@ -26,12 +26,17 @@ export class ConstantPool { /** * Imports new constants into this pool. * + * A key is a hash of the value it names, so importing one the pool already + * holds is importing the same value again, which happens when a message is + * read before it is processed: what an invocation runs is read out of the + * pool, so the constants of a message go in as it arrives, and again when + * it is processed. + * * @param json - a JSON object mapping constant keys to constant values, not * `null` */ importFromJson(json: Record): void { for (const key of Object.keys(json)) { - assert(!this.#constants.has(key), 'ConstantPool already contains a value for the imported key'); const value = json[key]; assert(value !== null && value !== undefined, 'ConstantPool constant value must not be null'); this.#constants.set(key, value); 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 230cabe3099..29bd19f8079 100644 --- a/flow-client/src/main/frontend/internal/client/flow/ExecuteJavaScriptProcessor.ts +++ b/flow-client/src/main/frontend/internal/client/flow/ExecuteJavaScriptProcessor.ts @@ -93,7 +93,7 @@ function reportThroughChannel(parameters: unknown[], message: string): void { * expression, and, outside production, what a developer wrote for each of * them. */ -function declaredJavaScript(): { +function getDeclaredJavaScript(): { jsDefinitions?: Record; jsDefinitionNames?: Record; } { @@ -114,7 +114,7 @@ function declaredJavaScript(): { * bundled code and nothing has to be compiled from a string here. */ function findDeclaredFunction(functionId: string): JsDefinitionFunction | undefined { - return declaredJavaScript().jsDefinitions?.[functionId]; + return getDeclaredJavaScript().jsDefinitions?.[functionId]; } /** @@ -122,8 +122,8 @@ function findDeclaredFunction(functionId: string): JsDefinitionFunction | undefi * development bundle registers next to the function itself, and the identifier * of the function when it does not, as in production. */ -function nameOf(functionId: string): string { - return declaredJavaScript().jsDefinitionNames?.[functionId] ?? functionId; +function getNameOf(functionId: string): string { + return getDeclaredJavaScript().jsDefinitionNames?.[functionId] ?? functionId; } /** @@ -292,7 +292,7 @@ export class ExecuteJavaScriptProcessor { * when the call is subscribed to */ protected invokeFromBundle(functionId: string, parameters: unknown[]): void { - const name = nameOf(functionId); + const name = getNameOf(functionId); const fn = findDeclaredFunction(functionId); if (fn === undefined) { const message = `No JavaScript in the bundle for ${name}. The JavaScript definition is annotated with @JsDefinition, but the build did not collect it.`; diff --git a/flow-client/src/test/frontend/internal/client/communication/MessageHandlerTests.ts b/flow-client/src/test/frontend/internal/client/communication/MessageHandlerTests.ts index 3ef651aaf30..1db04c207af 100644 --- a/flow-client/src/test/frontend/internal/client/communication/MessageHandlerTests.ts +++ b/flow-client/src/test/frontend/internal/client/communication/MessageHandlerTests.ts @@ -8,9 +8,10 @@ import { expect } from '@open-wc/testing'; import { MessageHandler, parseJson, - whatInvocationRuns + resolveWhatRuns } from '../../../../../main/frontend/internal/client/communication/MessageHandler'; import { ConstantPool } from '../../../../../main/frontend/internal/client/flow/ConstantPool'; +import { ResynchronizationState } from '../../../../../main/frontend/internal/client/communication/MessageSender'; import { DependencyLoader } from '../../../../../main/frontend/internal/client/DependencyLoader'; import { ResourceLoader } from '../../../../../main/frontend/internal/client/ResourceLoader'; import { runWhenEagerDependenciesLoaded } from '../../../../../main/frontend/internal/client/EagerDependencyTracker'; @@ -245,7 +246,10 @@ describe('MessageHandler', () => { // syncId 5 while expecting 1 -> queued, not applied. handler.handleMessage({ syncId: 5, constants: { skipped: 1 } }); - expect(registry.log.constants).to.deep.equal([{ first: 1 }]); // second not imported + // The constants of a message go into the pool as it arrives, since what + // an invocation of it runs is read out of there, but nothing of the + // message itself is applied + expect(registry.log.constants).to.deep.equal([{ first: 1 }, { skipped: 1 }]); expect(handler.getLastSeenServerSyncId()).to.equal(0); }); @@ -262,7 +266,9 @@ describe('MessageHandler', () => { // is ended. registry.startRequest(); handler.handleMessage({ syncId: 0, constants: { stale: 1 } }); - expect(registry.log.constants).to.deep.equal([]); // never applied any constants + // Nothing of the message is applied; its constants are in the pool the + // way those of any message that arrives are + expect(registry.log.constants).to.deep.equal([{ stale: 1 }]); expect(registry.log.endRequests).to.equal(endRequestsBefore + 1); }); @@ -521,17 +527,39 @@ describe('MessageHandler', () => { expect(profiling[0]).to.be.at.least(0); }); - it('reads what an invocation runs from this message or from the pool', () => { + it('reads what an invocation runs out of the pool', () => { // Which decides whether a forced reload during a resynchronization is - // seen, and the script of an invocation can come from either: the - // message that carries it, or the one that first sent it. + // seen; the constants of a message go into the pool as it arrives, so + // the one it carries is there along with the ones before it. const pool = new ConstantPool(); pool.importFromJson({ earlier: 'window.location.reload();' }); - expect(whatInvocationRuns([{}, 'now'], { now: 'history.back();' }, pool)).to.equal('history.back();'); - expect(whatInvocationRuns([{}, 'earlier'], {}, pool)).to.equal('window.location.reload();'); - expect(whatInvocationRuns([{}, 'neither'], {}, pool)).to.be.null; - expect(whatInvocationRuns([], {}, pool)).to.be.null; + expect(resolveWhatRuns([{}, 'earlier'], pool)).to.equal('window.location.reload();'); + expect(resolveWhatRuns([{}, 'neither'], pool)).to.be.null; + expect(resolveWhatRuns([], pool)).to.be.null; + }); + + it('takes the constants of a message in before deciding what to do with it', () => { + // What an invocation runs is read out of the pool, and a message that + // arrives while a resynchronization is ongoing is only queued, so its + // constants have to be in the pool by then. + const pool = new ConstantPool(); + const registry = testRegistry({ + MessageSender: { + getResynchronizationState: () => ResynchronizationState.WAITING_FOR_RESPONSE, + clearResynchronizationState: () => {}, + setClientToServerMessageId: () => {} + }, + ConstantPool: pool + }); + + new TestMessageHandler(registry).callHandleJSON({ + syncId: 3, + constants: { c: 'window.alert($0)' }, + execute: [['c']] + }); + + expect(pool.get('c')).to.equal('window.alert($0)'); }); it('keeps processing a message whose stylesheetRemovals is null', () => { diff --git a/flow-client/src/test/frontend/internal/client/flow/ConstantPoolTests.ts b/flow-client/src/test/frontend/internal/client/flow/ConstantPoolTests.ts index 7765e4e98fe..376e95b6375 100644 --- a/flow-client/src/test/frontend/internal/client/flow/ConstantPoolTests.ts +++ b/flow-client/src/test/frontend/internal/client/flow/ConstantPoolTests.ts @@ -26,6 +26,15 @@ describe('ConstantPool', () => { expect(pool.get('missing')).to.equal(null); }); + it('takes the same key again, since a key is a hash of its value', () => { + // A message is read before it is processed, so its constants are imported + // as it arrives and again when it is handled. + const pool = new ConstantPool(); + pool.importFromJson({ a: 'value-a' }); + pool.importFromJson({ a: 'value-a' }); + expect(pool.get('a')).to.equal('value-a'); + }); + it('accumulates constants across imports', () => { const pool = new ConstantPool(); pool.importFromJson({ a: '1' }); From bdbe8c1d23338318c37c77839c8e99b3f069e7f5 Mon Sep 17 00:00:00 2001 From: "totally-not-ai[bot]" <290682512+totally-not-ai[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 10:55:32 +0000 Subject: [PATCH 51/57] docs: say when the constants are taken in a second time The message they are imported from is read again when it was queued, which is not the same as when it is processed - the processing side reads the pool rather than filling it. Generating the file needs something to scan the definitions with, and a caller that writes the file again knows them already and passes them in, so going through the generating side without a class finder says which of the two it is rather than failing on a null. --- .../frontend/TaskGenerateJsDefinitions.java | 13 +++++++++++-- .../frontend/TaskGenerateJsDefinitionsTest.java | 17 +++++++++++++++++ .../internal/client/flow/ConstantPool.ts | 8 ++++---- 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitions.java b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitions.java index fd5d46598d8..8ec54c1496c 100644 --- a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitions.java +++ b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitions.java @@ -25,6 +25,7 @@ import java.util.Collection; import java.util.Comparator; import java.util.List; +import java.util.Objects; import java.util.stream.IntStream; import org.slf4j.Logger; @@ -34,6 +35,7 @@ import com.vaadin.flow.js.JsCall; import com.vaadin.flow.js.JsDefinition; import com.vaadin.flow.js.JsExpression; +import com.vaadin.flow.server.frontend.scanner.ClassFinder; import static com.vaadin.flow.internal.FrontendUtils.GENERATED; import static com.vaadin.flow.internal.FrontendUtils.JS_DEFINITIONS_FILE_NAME; @@ -84,8 +86,15 @@ public class TaskGenerateJsDefinitions extends AbstractTaskClientGenerator { @Override protected String getFileContent() { - return renderFileContent(options.getClassFinder().getAnnotatedClasses( - JsDefinition.class), !options.isProductionMode()); + // Generating the file is scanning for what goes into it, which a + // caller that writes it again while the application runs does not do: + // it passes the definitions in, through updateJsDefinitions + ClassFinder classFinder = Objects.requireNonNull( + options.getClassFinder(), + "Generating the file needs a class finder to scan for the JavaScript definitions with"); + return renderFileContent( + classFinder.getAnnotatedClasses(JsDefinition.class), + !options.isProductionMode()); } /** diff --git a/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitionsTest.java b/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitionsTest.java index 411f27e2b14..fbe0abfa220 100644 --- a/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitionsTest.java +++ b/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitionsTest.java @@ -40,6 +40,7 @@ import static com.vaadin.flow.internal.FrontendUtils.JS_DEFINITIONS_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.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assumptions.assumeTrue; @@ -355,6 +356,22 @@ void acceptsItsOwnUpdate() throws ExecutionFailedException { + content); } + @Test + void getFileContent_noClassFinder_saysWhatItIsFor() { + // The options a caller that writes the file again while the + // application runs builds: it knows the definitions, so it has nothing + // to scan with, and going through the generating side is a mistake + // that should say so + Options withoutAClassFinder = new Options(Mockito.mock(Lookup.class), + null, null).withFrontendDirectory(frontendFolder); + + assertTrue( + assertThrows(NullPointerException.class, + () -> new TaskGenerateJsDefinitions(withoutAClassFinder) + .getFileContent()) + .getMessage().contains("scan")); + } + @Test void writesTheFileTheBootstrapImports() throws ExecutionFailedException { task.execute(); diff --git a/flow-client/src/main/frontend/internal/client/flow/ConstantPool.ts b/flow-client/src/main/frontend/internal/client/flow/ConstantPool.ts index de012d64c3d..b3a4e24a85b 100644 --- a/flow-client/src/main/frontend/internal/client/flow/ConstantPool.ts +++ b/flow-client/src/main/frontend/internal/client/flow/ConstantPool.ts @@ -27,10 +27,10 @@ export class ConstantPool { * Imports new constants into this pool. * * A key is a hash of the value it names, so importing one the pool already - * holds is importing the same value again, which happens when a message is - * read before it is processed: what an invocation runs is read out of the - * pool, so the constants of a message go in as it arrives, and again when - * it is processed. + * holds is importing the same value again, which happens because a message + * is read before it is handled: what an invocation runs is read out of the + * pool, so the constants of a message go in as it arrives, and again if the + * message was queued and is handled later. * * @param json - a JSON object mapping constant keys to constant values, not * `null` From 3cfe77fe064535eaca59056bddbbae195cfb26e0 Mon Sep 17 00:00:00 2001 From: "totally-not-ai[bot]" <290682512+totally-not-ai[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:06:34 +0000 Subject: [PATCH 52/57] refactor: take the constants of a message in once, and say so once The pool said again that a key arrives once, and the message handler keeps that true: a message that is queued and read again is remembered, so its constants go in as it arrives and not a second time. The case for importing the same key twice goes with it, and one for a message that is read twice takes its place. The processing side no longer says where the constants went in, since nothing there does anything with them. --- .../client/communication/MessageHandler.ts | 12 ++++--- .../internal/client/flow/ConstantPool.ts | 7 +--- .../communication/MessageHandlerTests.ts | 32 +++++++++++++++++++ .../internal/client/flow/ConstantPoolTests.ts | 9 ------ 4 files changed, 40 insertions(+), 20 deletions(-) diff --git a/flow-client/src/main/frontend/internal/client/communication/MessageHandler.ts b/flow-client/src/main/frontend/internal/client/communication/MessageHandler.ts index 93e2d6598a6..e78dd3dd409 100644 --- a/flow-client/src/main/frontend/internal/client/communication/MessageHandler.ts +++ b/flow-client/src/main/frontend/internal/client/communication/MessageHandler.ts @@ -112,6 +112,10 @@ export class MessageHandler { // The server-sync-id ordering state + the queue of pending messages. readonly #ordering = new PendingMessageQueue(); + // The messages whose constants are in the pool, so that one that is queued + // and read again is not imported twice + readonly #importedConstantsOf = new WeakSet(); + #csrfToken = CSRF_TOKEN_DEFAULT_VALUE; #pushId: string | null = null; @@ -185,9 +189,9 @@ export class MessageHandler { // Before anything decides what to do with the message, since what an // invocation of it runs is a constant of it, and that decides whether a // forced reload is what arrived. A message that is queued here is read - // again when it is handled, and a key is a hash of its value, so the - // second import is the same values. - if ('constants' in valueMap) { + // again when it is handled, so the ones already taken in are remembered. + if ('constants' in valueMap && !this.#importedConstantsOf.has(valueMap)) { + this.#importedConstantsOf.add(valueMap); this.#registry.getConstantPool().importFromJson(valueMap.constants as Record); } @@ -348,8 +352,6 @@ export class MessageHandler { } try { const processUidlStart = performance.now(); - // The constants went into the pool as the message arrived, which is - // before anything reads what one of its invocations runs if ('changes' in valueMap) { this.#processChanges(valueMap); } diff --git a/flow-client/src/main/frontend/internal/client/flow/ConstantPool.ts b/flow-client/src/main/frontend/internal/client/flow/ConstantPool.ts index b3a4e24a85b..5bd3917fdc7 100644 --- a/flow-client/src/main/frontend/internal/client/flow/ConstantPool.ts +++ b/flow-client/src/main/frontend/internal/client/flow/ConstantPool.ts @@ -26,17 +26,12 @@ export class ConstantPool { /** * Imports new constants into this pool. * - * A key is a hash of the value it names, so importing one the pool already - * holds is importing the same value again, which happens because a message - * is read before it is handled: what an invocation runs is read out of the - * pool, so the constants of a message go in as it arrives, and again if the - * message was queued and is handled later. - * * @param json - a JSON object mapping constant keys to constant values, not * `null` */ importFromJson(json: Record): void { for (const key of Object.keys(json)) { + assert(!this.#constants.has(key), 'ConstantPool already contains a value for the imported key'); const value = json[key]; assert(value !== null && value !== undefined, 'ConstantPool constant value must not be null'); this.#constants.set(key, value); diff --git a/flow-client/src/test/frontend/internal/client/communication/MessageHandlerTests.ts b/flow-client/src/test/frontend/internal/client/communication/MessageHandlerTests.ts index 1db04c207af..e97c90b26ae 100644 --- a/flow-client/src/test/frontend/internal/client/communication/MessageHandlerTests.ts +++ b/flow-client/src/test/frontend/internal/client/communication/MessageHandlerTests.ts @@ -539,6 +539,38 @@ describe('MessageHandler', () => { expect(resolveWhatRuns([], pool)).to.be.null; }); + it('takes the constants of a message in once, however often it is read', () => { + // A message that arrives while a resynchronization is ongoing is + // queued and read again when it is handled. + const log: unknown[] = []; + const registry = testRegistry({ + MessageSender: { + getResynchronizationState: () => ResynchronizationState.NOT_ACTIVE, + clearResynchronizationState: () => {}, + setClientToServerMessageId: () => {} + }, + ConstantPool: new ConstantPool(), + RequestResponseTracker: { + fireResponseHandlingStarted: () => {}, + endRequest: () => {}, + hasActiveRequest: () => true + }, + LoadingIndicatorStateHandler: { stopLoading: () => {} }, + ApplicationConfiguration: { getMaxMessageSuspendTimeout: () => 10000 }, + StateTree: { prepareForResync: () => {} }, + ExecuteJavaScriptProcessor: { execute: (invocations: unknown) => log.push(invocations) } + }); + const handler = new TestMessageHandler(registry); + const message = { syncId: 5, constants: { c: 'window.alert($0)' } }; + + // Out of order, so it is queued, and handled once the one before it + // arrives + handler.callHandleJSON(message); + handler.callHandleJSON({ syncId: 0 }); + + expect(registry.getConstantPool().get('c')).to.equal('window.alert($0)'); + }); + it('takes the constants of a message in before deciding what to do with it', () => { // What an invocation runs is read out of the pool, and a message that // arrives while a resynchronization is ongoing is only queued, so its diff --git a/flow-client/src/test/frontend/internal/client/flow/ConstantPoolTests.ts b/flow-client/src/test/frontend/internal/client/flow/ConstantPoolTests.ts index 376e95b6375..7765e4e98fe 100644 --- a/flow-client/src/test/frontend/internal/client/flow/ConstantPoolTests.ts +++ b/flow-client/src/test/frontend/internal/client/flow/ConstantPoolTests.ts @@ -26,15 +26,6 @@ describe('ConstantPool', () => { expect(pool.get('missing')).to.equal(null); }); - it('takes the same key again, since a key is a hash of its value', () => { - // A message is read before it is processed, so its constants are imported - // as it arrives and again when it is handled. - const pool = new ConstantPool(); - pool.importFromJson({ a: 'value-a' }); - pool.importFromJson({ a: 'value-a' }); - expect(pool.get('a')).to.equal('value-a'); - }); - it('accumulates constants across imports', () => { const pool = new ConstantPool(); pool.importFromJson({ a: '1' }); From 28c928fe5366b29c7d4047937b521df755be9b77 Mon Sep 17 00:00:00 2001 From: "totally-not-ai[bot]" <290682512+totally-not-ai[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:11:22 +0000 Subject: [PATCH 53/57] fix: let a key that arrives again name what the pool already holds The pool threw on a key it already had, and a message can reach the client more than once - the server re-sends one, and the client ignores it as already seen - so the constants that message carries would have thrown out of the reading that happens before that. A key is a hash of the value it names, so a key that is already there is taken as the value that is already there, and what is refused is a key that names something else. The handler no longer remembers which messages it has read, since nothing depends on reading one only once. The case that went with the bookkeeping is replaced by the two that say what the behaviour is: a message the server re-sends is read again without anything being thrown, and the pool refuses a key that names a second value. --- .../client/communication/MessageHandler.ts | 12 ++---- .../internal/client/flow/ConstantPool.ts | 15 +++++++- .../communication/MessageHandlerTests.ts | 38 +++++-------------- .../internal/client/flow/ConstantPoolTests.ts | 13 +++++++ 4 files changed, 41 insertions(+), 37 deletions(-) diff --git a/flow-client/src/main/frontend/internal/client/communication/MessageHandler.ts b/flow-client/src/main/frontend/internal/client/communication/MessageHandler.ts index e78dd3dd409..b7af39f30b5 100644 --- a/flow-client/src/main/frontend/internal/client/communication/MessageHandler.ts +++ b/flow-client/src/main/frontend/internal/client/communication/MessageHandler.ts @@ -112,10 +112,6 @@ export class MessageHandler { // The server-sync-id ordering state + the queue of pending messages. readonly #ordering = new PendingMessageQueue(); - // The messages whose constants are in the pool, so that one that is queued - // and read again is not imported twice - readonly #importedConstantsOf = new WeakSet(); - #csrfToken = CSRF_TOKEN_DEFAULT_VALUE; #pushId: string | null = null; @@ -188,10 +184,10 @@ export class MessageHandler { // Before anything decides what to do with the message, since what an // invocation of it runs is a constant of it, and that decides whether a - // forced reload is what arrived. A message that is queued here is read - // again when it is handled, so the ones already taken in are remembered. - if ('constants' in valueMap && !this.#importedConstantsOf.has(valueMap)) { - this.#importedConstantsOf.add(valueMap); + // forced reload is what arrived. A message read more than once - queued + // and handled later, or re-sent by the server - carries the constants it + // carried before, which the pool takes as the values it already holds. + if ('constants' in valueMap) { this.#registry.getConstantPool().importFromJson(valueMap.constants as Record); } diff --git a/flow-client/src/main/frontend/internal/client/flow/ConstantPool.ts b/flow-client/src/main/frontend/internal/client/flow/ConstantPool.ts index 5bd3917fdc7..97238921128 100644 --- a/flow-client/src/main/frontend/internal/client/flow/ConstantPool.ts +++ b/flow-client/src/main/frontend/internal/client/flow/ConstantPool.ts @@ -26,14 +26,27 @@ export class ConstantPool { /** * Imports new constants into this pool. * + * A key is a hash of the value it names, so a key that is already here + * names what is already here: the server sends a constant once, but the + * message carrying it can reach the client more than once - it is re-sent, + * or it is read once as it arrives and again when it is handled - and every + * one of those carries the same value. What is refused is a key that names + * something else. + * * @param json - a JSON object mapping constant keys to constant values, not * `null` */ importFromJson(json: Record): void { for (const key of Object.keys(json)) { - assert(!this.#constants.has(key), 'ConstantPool already contains a value for the imported key'); const value = json[key]; assert(value !== null && value !== undefined, 'ConstantPool constant value must not be null'); + if (this.#constants.has(key)) { + assert( + JSON.stringify(this.#constants.get(key)) === JSON.stringify(value), + 'ConstantPool already contains another value for the imported key' + ); + continue; + } this.#constants.set(key, value); } } diff --git a/flow-client/src/test/frontend/internal/client/communication/MessageHandlerTests.ts b/flow-client/src/test/frontend/internal/client/communication/MessageHandlerTests.ts index e97c90b26ae..0f739fe9b63 100644 --- a/flow-client/src/test/frontend/internal/client/communication/MessageHandlerTests.ts +++ b/flow-client/src/test/frontend/internal/client/communication/MessageHandlerTests.ts @@ -539,36 +539,18 @@ describe('MessageHandler', () => { expect(resolveWhatRuns([], pool)).to.be.null; }); - it('takes the constants of a message in once, however often it is read', () => { - // A message that arrives while a resynchronization is ongoing is - // queued and read again when it is handled. - const log: unknown[] = []; - const registry = testRegistry({ - MessageSender: { - getResynchronizationState: () => ResynchronizationState.NOT_ACTIVE, - clearResynchronizationState: () => {}, - setClientToServerMessageId: () => {} - }, - ConstantPool: new ConstantPool(), - RequestResponseTracker: { - fireResponseHandlingStarted: () => {}, - endRequest: () => {}, - hasActiveRequest: () => true - }, - LoadingIndicatorStateHandler: { stopLoading: () => {} }, - ApplicationConfiguration: { getMaxMessageSuspendTimeout: () => 10000 }, - StateTree: { prepareForResync: () => {} }, - ExecuteJavaScriptProcessor: { execute: (invocations: unknown) => log.push(invocations) } - }); - const handler = new TestMessageHandler(registry); - const message = { syncId: 5, constants: { c: 'window.alert($0)' } }; + it('takes the constants of a message the server re-sends', () => { + // The message is ignored as already seen, but its constants are read + // before that, as they are for any message that arrives. They name + // what the pool holds, which is what the pool makes of a key it has. + const registry = makeRegistry(); + const handler = new MessageHandler(registry.registry); + handler.handleMessage({ syncId: 0, constants: { c: 'window.alert($0)' } }); - // Out of order, so it is queued, and handled once the one before it - // arrives - handler.callHandleJSON(message); - handler.callHandleJSON({ syncId: 0 }); + registry.startRequest(); + handler.handleMessage({ syncId: 0, constants: { c: 'window.alert($0)' } }); - expect(registry.getConstantPool().get('c')).to.equal('window.alert($0)'); + expect(registry.log.constants).to.deep.equal([{ c: 'window.alert($0)' }, { c: 'window.alert($0)' }]); }); it('takes the constants of a message in before deciding what to do with it', () => { diff --git a/flow-client/src/test/frontend/internal/client/flow/ConstantPoolTests.ts b/flow-client/src/test/frontend/internal/client/flow/ConstantPoolTests.ts index 7765e4e98fe..4e63efdbf3e 100644 --- a/flow-client/src/test/frontend/internal/client/flow/ConstantPoolTests.ts +++ b/flow-client/src/test/frontend/internal/client/flow/ConstantPoolTests.ts @@ -26,6 +26,19 @@ describe('ConstantPool', () => { expect(pool.get('missing')).to.equal(null); }); + it('takes a key it already holds, and refuses another value under it', () => { + // The message a constant arrives in can reach the client more than once, + // and a key is a hash of the value it names, so the same key is the same + // value. Anything else is a key that means two things. + const pool = new ConstantPool(); + pool.importFromJson({ a: { text: 'value-a' } }); + + pool.importFromJson({ a: { text: 'value-a' } }); + expect(pool.get>('a')).to.deep.equal({ text: 'value-a' }); + + expect(() => pool.importFromJson({ a: { text: 'something else' } })).to.throw(); + }); + it('accumulates constants across imports', () => { const pool = new ConstantPool(); pool.importFromJson({ a: '1' }); From ee706818bf76721359d6715d7ceca318c8e08893 Mon Sep 17 00:00:00 2001 From: "totally-not-ai[bot]" <290682512+totally-not-ai[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:21:36 +0000 Subject: [PATCH 54/57] refactor: name the function in an object, so nothing else can look like one The constant an invocation of declared JavaScript names was the identifier of a function, a string, and so is the constant an invocation of an expression names. Telling them apart went by the shape of the string, which an expression of exactly that shape would have fooled. The constant is now `{"f": ""}`, and what tells the two apart is that one is an object and the other is a string. It costs the two characters of the key once per function, since a constant is sent once. --- .../client/flow/ExecuteJavaScriptProcessor.ts | 23 ++++++---- .../flow/ExecuteJavaScriptProcessorTests.ts | 16 +++---- .../flow/server/communication/UidlWriter.java | 20 +++++---- .../com/vaadin/flow/shared/JsonConstants.java | 8 ++++ .../server/communication/UidlWriterTest.java | 42 +++++++++++++------ 5 files changed, 73 insertions(+), 36 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 29bd19f8079..ff3446d5222 100644 --- a/flow-client/src/main/frontend/internal/client/flow/ExecuteJavaScriptProcessor.ts +++ b/flow-client/src/main/frontend/internal/client/flow/ExecuteJavaScriptProcessor.ts @@ -68,11 +68,16 @@ interface ContextCallbacks { type JsDefinitionFunction = (this: unknown, ...args: unknown[]) => unknown; -// What the server sends instead of an expression: the identifier of a function -// of the bundle, which is a hash of the JavaScript it runs. Anything else it -// sends is an expression, and one of these is not valid JavaScript, so an -// identifier that the bundle does not have is reported rather than run. -const FUNCTION_ID = /^[0-9a-f]{64}$/u; +/** + * What an invocation of declared JavaScript names instead of an expression: + * the function of the bundle to run, identified by a hash of the JavaScript it + * runs. An invocation that runs an expression names the expression itself, a + * string, so the two are told apart by what the constant is rather than by + * what it says. + */ +interface JsFunctionConstant { + f: string; +} type ReturnChannel = (value: unknown) => void; @@ -187,7 +192,9 @@ export class ExecuteJavaScriptProcessor { // What to run is a constant of the message, the same way for an // expression and for a call of declared JavaScript, so an expression the // server runs again costs a reference rather than its own text. - const whatToRun = this.#registry.getConstantPool().get(invocation[invocation.length - 1] as string); + const whatToRun = this.#registry + .getConstantPool() + .get(invocation[invocation.length - 1] as string); if (whatToRun === null) { Console.error( `No constant for the invocation ${JSON.stringify(invocation)}. Reload the page to pick up the current state.` @@ -195,13 +202,13 @@ export class ExecuteJavaScriptProcessor { return; } - if (FUNCTION_ID.test(whatToRun)) { + if (typeof whatToRun === 'object') { // A call of declared JavaScript: the bundle has the function, the // server sent only 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(whatToRun, parameters); + this.invokeFromBundle(whatToRun.f, parameters); 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 cf302030834..40648af1810 100644 --- a/flow-client/src/test/frontend/internal/client/flow/ExecuteJavaScriptProcessorTests.ts +++ b/flow-client/src/test/frontend/internal/client/flow/ExecuteJavaScriptProcessorTests.ts @@ -86,10 +86,12 @@ function registeredNode(registry: TestRegistry, id: number): StateNode { describe('ExecuteJavaScriptProcessor', () => { describe('JavaScript definition calls', () => { - // What the server sends: the identifier of a function of the bundle, which - // is a hash of the JavaScript it runs + // What the server sends: an object naming a function of the bundle, which + // is identified by a hash of the JavaScript it runs const GREETING = 'a'.repeat(64); const VALUE = 'b'.repeat(64); + const greeting = { f: GREETING }; + const value = { f: VALUE }; type DefinitionFunction = (this: unknown, ...args: unknown[]) => unknown; @@ -142,7 +144,7 @@ describe('ExecuteJavaScriptProcessor', () => { }); const element = { tagName: 'div' }; - run(['Hello', element, GREETING]); + run(['Hello', element, greeting]); expect(calls).to.have.lengthOf(1); expect(calls[0].thisArg).to.equal(element); @@ -154,7 +156,7 @@ describe('ExecuteJavaScriptProcessor', () => { const resolved: unknown[] = []; const element = { tagName: 'div' }; - run([element, (value: unknown) => resolved.push(value), () => {}, VALUE]); + run([element, (returned: unknown) => resolved.push(returned), () => {}, value]); // Settled in microtasks: a macrotask wait would also pick up the // asynchronous rethrow that the expression cases leave behind. await Promise.resolve(); @@ -173,7 +175,7 @@ describe('ExecuteJavaScriptProcessor', () => { // invocation and the bundle disagree about the signature, which is the // same disagreement as an invocation that carries one parameter too // many. - run(['Hello', GREETING]); + run(['Hello', greeting]); expect(calls).to.equal(0); }); @@ -192,7 +194,7 @@ describe('ExecuteJavaScriptProcessor', () => { const element = { tagName: 'div' }; // Subscribed to, but one channel short of what the server sends. - run([element, (error: unknown) => errors.push(error), VALUE]); + run([element, (error: unknown) => errors.push(error), value]); expect(calls).to.equal(0); // Reported rather than left hanging: the pending result on the server @@ -207,7 +209,7 @@ describe('ExecuteJavaScriptProcessor', () => { const errors: unknown[] = []; const element = { tagName: 'div' }; - run([element, () => {}, (error: unknown) => errors.push(error), VALUE]); + run([element, () => {}, (error: unknown) => errors.push(error), value]); expect(errors).to.have.lengthOf(1); // Nothing registered it, so the message has only the identifier 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 5cf3ebe5df2..e7e1a02c9ae 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 @@ -412,9 +412,11 @@ private static JsonNode constantOf(JsonNode whatToRun, * Encodes a call made through a JavaScript definition as * [argument1, ..., element, successChannel, errorChannel, function], * where the trailing constant names the function to run rather than - * carrying JavaScript. The name is a hash of the JavaScript the function - * runs, so no expression is sent, nothing is compiled in the browser, and - * what declared the JavaScript in Java stays on the server. + * carrying JavaScript. The constant is an object naming the function, which + * is what tells it apart from the constant of an invocation that runs an + * expression, a string. The function is named by a hash of the JavaScript + * it runs, so no expression is sent, nothing is compiled in the browser, + * and what declared the JavaScript in Java stays on the server. *

* The parameters are the arguments of the call, then the element to apply * the function to, and the two return value channels when the call is @@ -441,14 +443,14 @@ private static ArrayNode encodeJsCall( Stream.of(successChannel, errorChannel)); } + ObjectNode function = JacksonUtils.createObjectNode(); + function.put(JsonConstants.UIDL_KEY_JS_FUNCTION, + JsCall.functionId(invocation.getInvocation().getExpression(), + call.arguments().size())); + return Stream .concat(parameters.map(JacksonCodec::encodeWithTypeInfo), - Stream.of(constantOf( - JacksonUtils.createNode(JsCall.functionId( - invocation.getInvocation() - .getExpression(), - call.arguments().size())), - constantPool))) + Stream.of(constantOf(function, constantPool))) .collect(JacksonUtils.asArray()); } 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..90b7e677ce5 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,14 @@ public class JsonConstants implements Serializable { */ public static final String UIDL_KEY_EXECUTE = "execute"; + /** + * Key of the function to run in the constant that an invocation of declared + * JavaScript names, in place of the expression that an invocation of an + * expression names. The value identifies the JavaScript that the bundle + * holds, and says nothing about the Java that declared it. + */ + public static final String UIDL_KEY_JS_FUNCTION = "f"; + /** * Key used to hold the feature id when synchronizing node values. */ 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 d2d91340dd0..ad637c43000 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 @@ -31,6 +31,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.parallel.Isolated; import org.mockito.Mockito; +import tools.jackson.databind.JsonNode; import tools.jackson.databind.node.ArrayNode; import tools.jackson.databind.node.ObjectNode; @@ -199,13 +200,15 @@ void testEncodeExecuteJavaScript_npmMode() { JacksonUtils.createArray( // Null since element is not attached JacksonUtils.nullNode(), - JacksonUtils.createNode( - nameOfWhatRuns("$0.focus()", constants))), + JacksonUtils.createNode(nameOfWhatRuns( + JacksonUtils.createNode("$0.focus()"), + constants))), JacksonUtils.createArray( JacksonUtils.createNode("Lives remaining:"), JacksonUtils.createNode(3), JacksonUtils.createNode(nameOfWhatRuns( - "console.log($0, $1)", constants)))); + JacksonUtils.createNode("console.log($0, $1)"), + constants)))); assertTrue(JacksonUtils.jsonEquals(expectedJson, json), "an invocation should name what it runs among the constants of the message: " @@ -213,19 +216,30 @@ void testEncodeExecuteJavaScript_npmMode() { } /** - * What names the given script, or the given function, among the given - * constants, which is what an invocation that runs it carries instead of - * the script or the function itself. + * What names the given constant among the given ones, which is what an + * invocation that runs it carries instead of the constant itself. */ - private static String nameOfWhatRuns(String whatRuns, + private static String nameOfWhatRuns(JsonNode whatRuns, ObjectNode constants) { return JacksonUtils.getKeys(constants).stream() - .filter(key -> whatRuns.equals(constants.get(key).asString())) + .filter(key -> JacksonUtils.jsonEquals(whatRuns, + constants.get(key))) .findFirst() .orElseThrow(() -> new AssertionError("The constants " + constants + " should carry " + whatRuns)); } + /** + * The constant that names the function of the JavaScript declared for the + * given number of arguments, which is what a call of it runs. + */ + private static ObjectNode functionConstant(String expression, + int argumentCount) { + ObjectNode constant = JacksonUtils.createObjectNode(); + constant.put("f", JsCall.functionId(expression, argumentCount)); + return constant; + } + @Test void encodeExecuteJavaScript_sameScriptTwice_sentOnceAndNamedTwice() { Element element = ElementFactory.createDiv(); @@ -269,16 +283,20 @@ void encodeExecuteJavaScript_jsCall_sendsTheTargetInsteadOfTheScript() { constantPool); ObjectNode constants = constantPool.dumpConstants(); - String functionId = JsCall.functionId("this.method($0)", 1); ArrayNode expectedJson = JacksonUtils.createArray( JacksonUtils.createArray(JacksonUtils.createNode("foo"), // Null since element is not attached - JacksonUtils.nullNode(), JacksonUtils.createNode( - nameOfWhatRuns(functionId, constants)))); + JacksonUtils.nullNode(), + JacksonUtils.createNode(nameOfWhatRuns( + functionConstant("this.method($0)", 1), + constants)))); assertTrue(JacksonUtils.jsonEquals(expectedJson, json), "a call of declared JavaScript should name a function, the same way an expression names a script: " + json + " " + constants); + assertTrue(constants.toString().contains("\"f\":"), + "and the constant should be an object naming it, which is what tells it apart from an expression: " + + constants); assertFalse(constants.toString().contains(TestJs.class.getName()), "and the constant should carry neither JavaScript nor what declared it: " + constants); @@ -305,7 +323,7 @@ void encodeExecuteJavaScript_subscribedDefinitionCall_addsTheReturnChannels() { "the argument and the element should be followed by the two channels and the function to run: " + encoded); assertEquals( - nameOfWhatRuns(JsCall.functionId("this.method($0)", 1), + nameOfWhatRuns(functionConstant("this.method($0)", 1), constantPool.dumpConstants()), encoded.get(4).asString(), "and the function should be the same one as for a call that is not subscribed to: " From a22affb310343af16dff4f02856357e26d94ff69 Mon Sep 17 00:00:00 2001 From: "totally-not-ai[bot]" <290682512+totally-not-ai[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:26:07 +0000 Subject: [PATCH 55/57] test: pin that a string constant is an expression, whatever it reads like The point of naming a function in an object is that nothing else can be taken for one, so a case runs an invocation whose constant is a string of exactly the shape an identifier has and asserts that it is run as an expression. A check by shape would pass the suite without it. The key of that object is written once on each side now: the client mirror of the JSON constants carries it, and the type of the constant is built from it. The assertion that the constant is an object naming the function was already made by the comparison above it, which is against exactly that object. --- .../client/flow/ExecuteJavaScriptProcessor.ts | 7 +++---- .../frontend/internal/flow/shared/JsonConstants.ts | 7 +++++++ .../client/flow/ExecuteJavaScriptProcessorTests.ts | 11 +++++++++++ .../flow/server/communication/UidlWriterTest.java | 3 --- 4 files changed, 21 insertions(+), 7 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 ff3446d5222..47e48fdccf6 100644 --- a/flow-client/src/main/frontend/internal/client/flow/ExecuteJavaScriptProcessor.ts +++ b/flow-client/src/main/frontend/internal/client/flow/ExecuteJavaScriptProcessor.ts @@ -48,6 +48,7 @@ import { Reactive } from './reactive/Reactive'; import type { StateNode } from './StateNode'; import { UIState } from '../UILifecycle'; import { Console } from '../Console'; +import { JsonConstants } from '../../flow/shared/JsonConstants'; // NodeFeatures.NodeFeatures.ELEMENT_DATA / NodeProperties @@ -75,9 +76,7 @@ type JsDefinitionFunction = (this: unknown, ...args: unknown[]) => unknown; * string, so the two are told apart by what the constant is rather than by * what it says. */ -interface JsFunctionConstant { - f: string; -} +type JsFunctionConstant = Record; type ReturnChannel = (value: unknown) => void; @@ -208,7 +207,7 @@ export class ExecuteJavaScriptProcessor { // 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(whatToRun.f, parameters); + this.invokeFromBundle(whatToRun[JsonConstants.UIDL_KEY_JS_FUNCTION], parameters); return; } diff --git a/flow-client/src/main/frontend/internal/flow/shared/JsonConstants.ts b/flow-client/src/main/frontend/internal/flow/shared/JsonConstants.ts index e58908cb9e4..6c90ba46822 100644 --- a/flow-client/src/main/frontend/internal/flow/shared/JsonConstants.ts +++ b/flow-client/src/main/frontend/internal/flow/shared/JsonConstants.ts @@ -148,6 +148,13 @@ export const JsonConstants = { */ RPC_EVENT_DATA: 'data', + /** + * Key of the function to run in the constant that an invocation of declared + * JavaScript names, in place of the expression that an invocation of an + * expression names. + */ + UIDL_KEY_JS_FUNCTION: 'f', + /** * Key used to hold the feature id when synchronizing node values. */ 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 40648af1810..0a1c5e476d1 100644 --- a/flow-client/src/test/frontend/internal/client/flow/ExecuteJavaScriptProcessorTests.ts +++ b/flow-client/src/test/frontend/internal/client/flow/ExecuteJavaScriptProcessorTests.ts @@ -251,6 +251,17 @@ describe('ExecuteJavaScriptProcessor', () => { expect(processor.parameterNamesAndCodeList).to.have.length(0); }); + it('runs a string constant as an expression, whatever it looks like', () => { + // A function is named by an object, so an expression that happens to + // read like the identifier of one is still an expression + const registry = treeRegistry(); + const processor = new CollectingExecuteJavaScriptProcessor(registry); + + execute(processor, registry, [['a'.repeat(64)]]); + + expect(processor.parameterNamesAndCodeList).to.deep.equal([['a'.repeat(64)]]); + }); + it('passes a node parameter as the element it is bound to', () => { // Ported from execute_nodeParametersAreCorrectlyPassed. const registry = treeRegistry({ existingElementMap: true }); 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 ad637c43000..18d252e3fdc 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 @@ -294,9 +294,6 @@ void encodeExecuteJavaScript_jsCall_sendsTheTargetInsteadOfTheScript() { assertTrue(JacksonUtils.jsonEquals(expectedJson, json), "a call of declared JavaScript should name a function, the same way an expression names a script: " + json + " " + constants); - assertTrue(constants.toString().contains("\"f\":"), - "and the constant should be an object naming it, which is what tells it apart from an expression: " - + constants); assertFalse(constants.toString().contains(TestJs.class.getName()), "and the constant should carry neither JavaScript nor what declared it: " + constants); From 2d766be353aeb32b99bdd73b67e0636148ee6f0e Mon Sep 17 00:00:00 2001 From: "totally-not-ai[bot]" <290682512+totally-not-ai[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:42:39 +0000 Subject: [PATCH 56/57] refactor: say one thing about a method that is wrong in two ways A method that declares no JavaScript and could not be answered with what it returns went into both lists, and only the first of them is ever said. It goes into the one that names what to do about it, so what the lists hold is what they say they hold. --- .../src/main/java/com/vaadin/flow/js/JsDefinitionProxy.java | 1 + 1 file changed, 1 insertion(+) diff --git a/flow-server/src/main/java/com/vaadin/flow/js/JsDefinitionProxy.java b/flow-server/src/main/java/com/vaadin/flow/js/JsDefinitionProxy.java index 4bd44240cf7..9264f94cd5a 100644 --- a/flow-server/src/main/java/com/vaadin/flow/js/JsDefinitionProxy.java +++ b/flow-server/src/main/java/com/vaadin/flow/js/JsDefinitionProxy.java @@ -103,6 +103,7 @@ private static void checkMethods(Class definitionType) { } if (!method.isAnnotationPresent(JsExpression.class)) { undeclared.add(method.getName()); + continue; } Class returnType = method.getReturnType(); if (returnType != void.class && !returnType From 39953e133526e0b268f7c24fd27bbc760bec34ea Mon Sep 17 00:00:00 2001 From: "totally-not-ai[bot]" <290682512+totally-not-ai[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:44:08 +0000 Subject: [PATCH 57/57] docs: say that the guard keeps the lists apart, not that it changes a message The commit before this put a method that declares nothing into that list alone. Nothing a caller sees changed: the first list with anything in it is the one that is reported, and that has always been the one about declaring. A comment says so where it could be read the other way. --- .../src/main/java/com/vaadin/flow/js/JsDefinitionProxy.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/flow-server/src/main/java/com/vaadin/flow/js/JsDefinitionProxy.java b/flow-server/src/main/java/com/vaadin/flow/js/JsDefinitionProxy.java index 9264f94cd5a..23c3caa48a6 100644 --- a/flow-server/src/main/java/com/vaadin/flow/js/JsDefinitionProxy.java +++ b/flow-server/src/main/java/com/vaadin/flow/js/JsDefinitionProxy.java @@ -102,6 +102,10 @@ private static void checkMethods(Class definitionType) { continue; } if (!method.isAnnotationPresent(JsExpression.class)) { + // What it returns is beside the point until it declares + // something to return it from, and the first list that has + // anything in it is the one that is reported, so this changes + // what the lists hold rather than what is said undeclared.add(method.getName()); continue; }