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..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,6 +278,17 @@ private static boolean needsBuildInternal(Options options,
((ObjectNode) statsJson.get(FRONTEND_HASHES_STATS_KEY)).remove(
FrontendUtils.GENERATED + FrontendUtils.COMMERCIAL_BANNER_JS);
+ if (jsDefinitionsChanged(options, statsJson)) {
+ UsageStatistics.markAsUsed(
+ "flow/rebundle-reason-changed-js-definitions", null);
+ return true;
+ }
+ // 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_DEFINITIONS_FILE_NAME);
+
if (!BundleValidationUtil.frontendImportsFound(statsJson, options)) {
UsageStatistics.markAsUsed(
"flow/rebundle-reason-missing-frontend-import", null);
@@ -993,6 +1004,37 @@ private static boolean isCommercialBannerConditionChanged(Options options,
return false;
}
+ /**
+ * 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, 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 jsDefinitionsChanged(Options options,
+ JsonNode statsJson) {
+ JsonNode frontendHashes = statsJson.get(FRONTEND_HASHES_STATS_KEY);
+ String jsDefinitionsPath = FrontendUtils.GENERATED
+ + FrontendUtils.JS_DEFINITIONS_FILE_NAME;
+ String content = new TaskGenerateJsDefinitions(options)
+ .getFileContent();
+
+ List faultyContent = new ArrayList<>();
+ 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 JavaScript definitions that the bundle does not carry");
+ 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..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,6 +76,7 @@ public class NodeTasks implements FallibleCommand {
TaskGenerateWebComponentHtml.class,
TaskGenerateWebComponentBootstrap.class,
TaskGenerateFeatureFlags.class,
+ TaskGenerateJsDefinitions.class,
TaskInstallFrontendBuildPlugins.class,
TaskUpdatePackages.class,
TaskRunNpmInstall.class,
@@ -262,6 +263,8 @@ public NodeTasks(Options options) {
commands.add(new TaskGenerateFeatureFlags(options));
+ commands.add(new TaskGenerateJsDefinitions(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..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,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_DEFINITIONS_FILE_NAME;
/**
* A task for generating the bootstrap file
@@ -83,6 +84,8 @@ protected String getFileContent() {
for (TypeScriptBootstrapModifier modifier : modifiers) {
modifier.modify(lines, options);
}
+ 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/TaskGenerateJsDefinitions.java b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitions.java
new file mode 100644
index 00000000000..8ec54c1496c
--- /dev/null
+++ b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitions.java
@@ -0,0 +1,386 @@
+/*
+ * 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.IOException;
+import java.lang.reflect.Method;
+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;
+import java.util.Objects;
+import java.util.stream.IntStream;
+
+import org.slf4j.Logger;
+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.server.frontend.scanner.ClassFinder;
+
+import static com.vaadin.flow.internal.FrontendUtils.GENERATED;
+import static com.vaadin.flow.internal.FrontendUtils.JS_DEFINITIONS_FILE_NAME;
+
+/**
+ * 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
+ * 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.
+ *
+ * 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 {
+
+ 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 || {};");
+
+ // 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
+ // 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;
+
+ TaskGenerateJsDefinitions(Options options) {
+ this.options = options;
+ }
+
+ @Override
+ protected String getFileContent() {
+ // 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());
+ }
+
+ /**
+ * Renders the file that registers the JavaScript of the given definition
+ * interfaces.
+ *
+ * Package private: what regenerating the file outside a build looks like is
+ * {@link #updateJsDefinitions(Options, Collection)}, which goes through
+ * this.
+ *
+ * @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,
+ boolean withNames) {
+ List lines = new ArrayList<>(renderHeader(withNames));
+ definitions.stream().sorted(Comparator.comparing(Class::getName))
+ .forEach(definition -> lines
+ .addAll(renderDefinitionLines(definition, withNames)));
+ lines.addAll(FOOTER);
+ return String.join(System.lineSeparator(), lines);
+ }
+
+ /**
+ * 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 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> findMissingFromGeneratedFile(Options options,
+ Collection> definitions) {
+ String generated = readGeneratedFile(options);
+ return definitions.stream()
+ .filter(definition -> !isInGeneratedFile(definition, generated))
+ .toList();
+ }
+
+ /**
+ * 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.
+ *
+ * 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
+ * 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
+ * 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 is, not null
+ * @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> updateJsDefinitions(Options options,
+ Collection> definitions) {
+ String generated = readGeneratedFile(options);
+ String content = withMissingEntries(generated, definitions);
+
+ 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 definitions.stream().filter(
+ definition -> !isInGeneratedFile(definition, generated))
+ .toList();
+ }
+ // Everything asked for went into the content that was written
+ return List.of();
+ }
+
+ /**
+ * Whether the given content carries what the definition declares, compared
+ * 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) {
+ if (generated == null) {
+ return false;
+ }
+ List declared = renderDefinitionLines(definition, true);
+ if (declared.isEmpty()) {
+ // Declares no JavaScript, so there is nothing to carry
+ return true;
+ }
+ return generated
+ .contains(String.join(System.lineSeparator(), declared));
+ }
+
+ /**
+ * 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, true);
+ }
+ List missing = definitions.stream()
+ .sorted(Comparator.comparing(Class::getName))
+ .flatMap(definition -> renderFunctions(definition, true)
+ .stream())
+ .filter(function -> !generated.contains(function))
+ .flatMap(function -> Arrays
+ .stream(function.split(System.lineSeparator())))
+ .toList();
+ if (missing.isEmpty()) {
+ return generated;
+ }
+ String separator = System.lineSeparator();
+ String footer = String.join(separator, FOOTER);
+ List added = new ArrayList<>();
+ 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);
+ }
+ 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, renderHeader(true)) + 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 renderHeader(boolean withNames) {
+ List header = new ArrayList<>(HEADER);
+ if (withNames) {
+ header.add(NAMES);
+ }
+ return header;
+ }
+
+ private static String readGeneratedFile(Options options) {
+ File generatedFile = new TaskGenerateJsDefinitions(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(TaskGenerateJsDefinitions.class);
+ }
+
+ /**
+ * Renders what one JavaScript definition contributes to the generated file:
+ * 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)},
+ * which compares against this.
+ *
+ * @param definition
+ * the JavaScript definition to render, not null
+ * @return the lines this definition contributes, empty if it declares no
+ * JavaScript
+ */
+ static List renderDefinitionLines(Class> definition,
+ boolean withNames) {
+ return renderFunctions(definition, withNames).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,
+ boolean withNames) {
+ List methods = new ArrayList<>();
+ for (Method method : definition.getMethods()) {
+ if (method.isAnnotationPresent(JsExpression.class)) {
+ methods.add(method);
+ }
+ }
+ 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
+ // 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)
+ .reduce((first, second) -> first + ", " + second)
+ .orElse("");
+ 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(),
+ 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_DEFINITIONS_FILE_NAME);
+ }
+
+ @Override
+ protected boolean shouldGenerate() {
+ // 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/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..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,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_DEFINITIONS_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_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 d0fa157551f..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;
@@ -203,6 +204,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 JavaScript
+ // definitions
+ frontendHashes.put(
+ FrontendUtils.GENERATED
+ + FrontendUtils.JS_DEFINITIONS_FILE_NAME,
+ BundleValidationUtil
+ .calculateHash(new TaskGenerateJsDefinitions(options)
+ .getFileContent()));
return stats;
}
@@ -1067,6 +1076,40 @@ 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("modesAndBundleHashes")
+ void jsDefinitionJavaScriptNotInTheBundle_bundleRebuild(Mode mode,
+ String bundleHash) {
+ setupMode(mode);
+
+ ObjectNode stats = getBasicStats();
+ 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,
+ depScanner, mode);
+
+ assertTrue(needsBuild,
+ "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
@MethodSource("modes")
void noFrontendFileHash_bundleRebuild(Mode mode) throws IOException {
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
new file mode 100644
index 00000000000..fbe0abfa220
--- /dev/null
+++ b/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateJsDefinitionsTest.java
@@ -0,0 +1,384 @@
+/*
+ * 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.IOException;
+import java.io.Serializable;
+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;
+import org.junit.jupiter.api.io.TempDir;
+import org.mockito.Mockito;
+
+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;
+
+import static com.vaadin.flow.internal.FrontendUtils.FRONTEND;
+import static com.vaadin.flow.internal.FrontendUtils.GENERATED;
+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;
+
+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)
+ void showGreeting(String greeting);
+
+ @JsExpression("window.alert('Hello')")
+ void showGreeting();
+ }
+
+ @JsDefinition
+ public interface CounterJs extends Serializable {
+ @JsExpression(COUNT_EXPRESSION)
+ void count(Integer from);
+ }
+
+ @JsDefinition
+ public interface NothingJs extends Serializable {
+ void notDeclared();
+ }
+
+ @TempDir
+ File temporaryFolder;
+
+ private TaskGenerateJsDefinitions task;
+ private Options options;
+ private File frontendFolder;
+
+ @BeforeEach
+ void setUp() {
+ frontendFolder = new File(temporaryFolder, FRONTEND);
+ frontendFolder.mkdirs();
+ options = new Options(Mockito.mock(Lookup.class),
+ new DefaultClassFinder(
+ Set.of(GreeterJs.class, NothingJs.class)),
+ null).withFrontendDirectory(frontendFolder)
+ .withProductionMode(false);
+ task = new TaskGenerateJsDefinitions(options);
+ }
+
+ @Test
+ void generatesAFunctionPerDeclaredExpression()
+ throws ExecutionFailedException {
+ task.execute();
+ String content = task.getFileContent();
+
+ assertTrue(
+ content.contains("window.Vaadin.Flow.jsDefinitions[\""
+ + 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(GREETING_EXPRESSION),
+ "the declared expression should be the body of the function, as it was written: "
+ + content);
+ assertTrue(
+ content.contains("window.Vaadin.Flow.jsDefinitions[\""
+ + JsCall.functionId("window.alert('Hello')", 0)
+ + "\"] = async function () {"),
+ "the overload that takes no arguments is another function: "
+ + content);
+ }
+
+ @Test
+ 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
+ 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: "
+ + content);
+ assertTrue(
+ content.contains("window.Vaadin.Flow.jsDefinitionNames[\""
+ + JsCall.functionId(GREETING_EXPRESSION, 1) + "\"] = \""
+ + GreeterJs.class.getName() + ".showGreeting/1\";"),
+ "and the name should be registered next to the function: "
+ + content);
+ }
+
+ @Test
+ void updateJsDefinitions_writesTheNamesAndWhatHoldsThem()
+ throws IOException {
+ // 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);
+ generated.getParentFile().mkdirs();
+ Files.writeString(generated.toPath(), TaskGenerateJsDefinitions
+ .renderFileContent(List.of(GreeterJs.class), false));
+
+ List> missing = TaskGenerateJsDefinitions
+ .updateJsDefinitions(options, 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_nothingMissing_leavesTheFileAlone()
+ throws IOException {
+ // 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);
+ generated.getParentFile().mkdirs();
+ String carried = TaskGenerateJsDefinitions
+ .renderFileContent(List.of(GreeterJs.class), true);
+ Files.writeString(generated.toPath(), carried);
+
+ TaskGenerateJsDefinitions.updateJsDefinitions(options,
+ 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_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: "
+ + content);
+ assertFalse(content.contains("showGreeting"),
+ "and not what the methods are called either: " + content);
+ }
+
+ @Test
+ void definitionWithoutDeclaredJavaScript_isNotRegistered()
+ throws ExecutionFailedException {
+ task.execute();
+ String content = task.getFileContent();
+
+ assertEquals(2,
+ content.split("window.Vaadin.Flow.jsDefinitions\\[\"",
+ -1).length - 1,
+ "only the two methods that declare JavaScript should be registered: "
+ + content);
+ }
+
+ @Test
+ void updateJsDefinitions_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 = 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");
+ } finally {
+ generatedFolder.setWritable(true);
+ }
+ }
+
+ @Test
+ void findMissingFromGeneratedFile_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.findMissingFromGeneratedFile(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.findMissingFromGeneratedFile(options,
+ List.of(GreeterJs.class)),
+ "another version of the declarations is not the declarations");
+
+ Files.delete(generated.toPath());
+ assertEquals(List.of(GreeterJs.class),
+ TaskGenerateJsDefinitions.findMissingFromGeneratedFile(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(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(COUNT_EXPRESSION, 1)),
+ "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(COUNT_EXPRESSION, 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 = "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')"));
+
+ 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();
+ 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 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();
+
+ assertTrue(
+ new File(new File(frontendFolder, GENERATED),
+ 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/communication/MessageHandler.ts b/flow-client/src/main/frontend/internal/client/communication/MessageHandler.ts
index 242073b9a91..b7af39f30b5 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';
@@ -181,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 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);
+ }
+
if (
!hasResynchronize &&
this.#registry.getMessageSender().getResynchronizationState() === ResynchronizationState.WAITING_FOR_RESPONSE
@@ -188,7 +198,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 (resolveWhatRuns(command, this.#registry.getConstantPool()) === 'window.location.reload();') {
Console.warn('Executing forced page reload while a resync request is ongoing.');
window.location.reload();
return;
@@ -338,9 +348,6 @@ export class MessageHandler {
}
try {
const processUidlStart = performance.now();
- if ('constants' in valueMap) {
- this.#registry.getConstantPool().importFromJson(valueMap.constants as Record);
- }
if ('changes' in valueMap) {
this.#processChanges(valueMap);
}
@@ -657,6 +664,22 @@ export class MessageHandler {
* @param jsonText - The JSON to parse
* @returns A ValueMap created from the JSON
*/
+/**
+ * Resolves what an invocation runs, which the invocation names rather than
+ * carries.
+ *
+ * @param invocation - the invocation, whose last element is the name
+ * @param constantPool - the constants the client has been sent
+ * @returns what to run, or `null` when nothing is named
+ */
+export function resolveWhatRuns(invocation: unknown[], constantPool: ConstantPool): unknown {
+ const name = invocation[invocation.length - 1];
+ if (typeof name !== 'string') {
+ return null;
+ }
+ return 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/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/main/frontend/internal/client/flow/ExecuteJavaScriptProcessor.ts b/flow-client/src/main/frontend/internal/client/flow/ExecuteJavaScriptProcessor.ts
index f46b8373d09..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
@@ -66,6 +67,69 @@ interface ContextCallbacks {
disposeInitializer: (node: StateNode, id: number) => void;
}
+type JsDefinitionFunction = (this: unknown, ...args: unknown[]) => unknown;
+
+/**
+ * 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.
+ */
+type JsFunctionConstant = Record;
+
+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);
+ }
+}
+
+/**
+ * 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 getDeclaredJavaScript(): {
+ 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 {
+ return getDeclaredJavaScript().jsDefinitions?.[functionId];
+}
+
+/**
+ * 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 getNameOf(functionId: string): string {
+ return getDeclaredJavaScript().jsDefinitionNames?.[functionId] ?? functionId;
+}
+
/**
* 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.
@@ -95,7 +159,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[] = [];
@@ -124,7 +188,30 @@ export class ExecuteJavaScriptProcessor {
}
}
- parameterNamesAndCode.push(invocation[invocation.length - 1] as string);
+ // 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(whatToRun[JsonConstants.UIDL_KEY_JS_FUNCTION], parameters);
+ return;
+ }
+
+ parameterNamesAndCode.push(whatToRun);
this.invoke(parameterNamesAndCode, parameters, nodeParameters);
}
@@ -195,6 +282,68 @@ export class ExecuteJavaScriptProcessor {
});
invokeJavaScript(parameterNamesAndCode, parameters, context, configuration.isProductionMode());
}
+
+ /**
+ * 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 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 call is subscribed to
+ */
+ protected invokeFromBundle(functionId: string, parameters: unknown[]): void {
+ 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.`;
+ Console.error(message);
+ // The server appends the two channels after everything else, or neither
+ // 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;
+ }
+
+ // 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);
+ 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];
+ 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 ${name}. Stacktrace will be dumped separately.`);
+ onError?.(`${exception}`);
+ }
+ }
}
/**
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/communication/MessageHandlerTests.ts b/flow-client/src/test/frontend/internal/client/communication/MessageHandlerTests.ts
index 77b4de1330d..0f739fe9b63 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,13 @@ 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,
+ 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';
@@ -240,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);
});
@@ -257,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);
});
@@ -516,6 +527,55 @@ describe('MessageHandler', () => {
expect(profiling[0]).to.be.at.least(0);
});
+ it('reads what an invocation runs out of the pool', () => {
+ // Which decides whether a forced reload during a resynchronization is
+ // 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(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 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)' } });
+
+ registry.startRequest();
+ handler.handleMessage({ syncId: 0, constants: { c: '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', () => {
+ // 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', () => {
const registry = makeRegistry();
const handler = new MessageHandler(registry.registry);
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' });
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..0a1c5e476d1 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());
@@ -69,12 +85,145 @@ function registeredNode(registry: TestRegistry, id: number): StateNode {
}
describe('ExecuteJavaScriptProcessor', () => {
+ describe('JavaScript definition calls', () => {
+ // 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;
+
+ type DefinitionWindow = Window & {
+ Vaadin?: {
+ Flow?: { jsDefinitions?: Record; jsDefinitionNames?: Record };
+ };
+ };
+
+ // 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 } {
+ 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(() => {
+ 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, greeting: unknown) {
+ calls.push({ thisArg: this, args: [greeting] });
+ });
+ const element = { tagName: 'div' };
+
+ run(['Hello', element, greeting]);
+
+ 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 () => {
+ registerDefinition(VALUE, () => 'answer');
+ const resolved: unknown[] = [];
+ const element = { tagName: 'div' };
+
+ 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();
+ await Promise.resolve();
+
+ expect(resolved).to.eql(['answer']);
+ });
+
+ it('does not run a call whose parameters do not match the function', () => {
+ let calls = 0;
+ registerDefinition(GREETING, (_greeting: unknown) => {
+ calls += 1;
+ });
+
+ // 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', greeting]);
+
+ expect(calls).to.equal(0);
+ });
+
+ it('reports a mismatch to the error channel, naming the function as it was written', () => {
+ let calls = 0;
+ 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 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), value]);
+
+ expect(errors).to.have.lengthOf(1);
+ // Nothing registered it, so the message has only the identifier
+ expect(String(errors[0])).to.contain(VALUE);
+ });
+ });
+
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);
@@ -90,6 +239,29 @@ 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('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 });
@@ -98,7 +270,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);
@@ -116,7 +288,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);
@@ -138,7 +310,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);
@@ -159,7 +331,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);
@@ -246,6 +418,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) }
@@ -253,6 +426,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;
@@ -264,6 +443,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: () => {} }
@@ -273,7 +453,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)']
]);
@@ -284,27 +464,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);
});
@@ -312,23 +491,21 @@ 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/component/Focusable.java b/flow-server/src/main/java/com/vaadin/flow/component/Focusable.java
index c6ac930cac3..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
@@ -15,9 +15,14 @@
*/
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.JsDefinition;
+import com.vaadin.flow.js.JsExpression;
/**
* Represents a component that can gain and lose focus.
@@ -134,34 +139,8 @@ 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(FocusJs.class)
+ .focus(FocusOption.buildOptions(options));
}
// for binary compatibility with the previous Vaadin versions
@@ -190,16 +169,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(FocusJs.class).blur();
}
/**
@@ -241,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 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.
+ */
+ @JsDefinition
+ 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 9a60b473134..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,6 +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.JsCall;
import com.vaadin.flow.router.AfterNavigationListener;
import com.vaadin.flow.router.BeforeEnterListener;
import com.vaadin.flow.router.BeforeLeaveEvent.ContinueNavigationAction;
@@ -128,6 +129,7 @@ public class UIInternals implements Serializable {
public static class JavaScriptInvocation implements Serializable {
private final String expression;
private final List