Skip to content

feat: run declared JavaScript without compiling it in the browser - #25749

Merged
Artur- merged 59 commits into
mainfrom
feat/js-invoker-seam-for-focusable
Sep 21, 2026
Merged

Artur- merged 59 commits into
mainfrom
feat/js-invoker-seam-for-focusable

Conversation

@totally-not-ai

@totally-not-ai totally-not-ai Bot commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Summary

A Java interface can now declare the JavaScript it runs, and the build collects that JavaScript into the frontend bundle. The server then tells the browser only which generated function to run, so server-initiated JavaScript no longer has to be compiled from a string and works under a Content Security Policy without unsafe-eval.

What changed

Behavior change: what executeJs puts on the wire (affects anyone reading the raw UIDL). Every scheduled invocation in the execute array now ends with a constant-pool key instead of the script text. The script itself is sent once per session under constants. Application code is unaffected, but custom clients or test tooling that read the script straight out of execute must resolve the constant first. The MPR push-state fix-up in UidlRequestHandler is updated here.

Behavior change: an existing frontend bundle is rebuilt once. The generated definitions file is hashed into the bundle stats, so a bundle built before this change counts as out of date and is rebuilt on the next build or start.

Behavior change: Focusable.focus(...) and blur() go through the new path. The visible effect is the same, but a driver that inspects pending invocations now sees a JsCall instead of an expression, and focus() without options passes null to the browser's focus() rather than calling it with no arguments.

Everything else is additive:

  • New @JsDefinition / @JsExpression annotations and Element.executeJs(Class), which hands out an implementation of the interface. Calling a method schedules the JavaScript the method declares, with the call arguments as $0, $1, … and the element as this — the same contract as executeJs(String, Object...). The interface is checked when the implementation is handed out.
  • TaskGenerateJsDefinitions writes generated/vaadin-js-definitions.js with one function per declared expression, keyed by a hash of the JavaScript. The bootstrap and web-component bootstrap import it, and Vite hashes it into the stats. A production bundle carries the functions only — the Java names stay on the server.
  • The scheduled invocation carries the call as a JsCall, so a browserless driver can recognize it or run it on its own implementation of the same interface.
  • On the client, ExecuteJavaScriptProcessor resolves what to run from the constant pool: an object constant means a bundled function applied to the element, a string constant is an expression as before. Missing functions and mismatched argument counts are reported through the error channel so a pending result is not left hanging.
  • The client constant pool now accepts a key it already holds when the value is the same (keys are hashes of the value); a key naming a different value is still refused. Constants are imported as a message arrives, so a forced reload is recognized during a resynchronization.
  • In dev mode, JsDefinitionHotswapper rewrites the generated file when a definition class changes (and warns when it cannot), and DevLoopRedefiner treats the declared JavaScript as a frontend dependency.

Use case

An app has to run under a strict CSP that forbids unsafe-eval, and a "Copy order id" button should put a value on the clipboard and report whether it worked. With a declared definition, the JavaScript lives in the bundle and nothing is compiled in the browser.

@JsDefinition
public interface ClipboardJs extends Serializable {

    @JsExpression("return navigator.clipboard.writeText($0).then(() => true, () => false);")
    PendingJavaScriptResult copyToClipboard(String text);
}
copyButton.addClickListener(event -> orderIdField.getElement()
        .executeJs(ClipboardJs.class)
        .copyToClipboard(order.getId())
        .then(Boolean.class, copied -> setCopyStatus(copied)));

API Changes

API Changes: feat/js-invoker-seam-for-focusable vs origin/main

12 classes affected, 23 members added, 0 removed, 0 changed.

com.vaadin.flow.js.JsDefinition

// Added
public @interface JsDefinition // marks an interface whose methods declare JavaScript, collected into the bundle by the build

com.vaadin.flow.js.JsExpression

// Added
public @interface JsExpression // the JavaScript a definition method runs
String value()

com.vaadin.flow.js.JsCall

// Added
public record JsCall(Class<?> definitionType, String methodName, List<Object> arguments) implements Serializable
public JsCall(Class<?> definitionType, String methodName, List<Object> arguments)
public Class<?> definitionType()
public String methodName()
public List<Object> arguments()
public static String functionId(String expression, int argumentCount) // identifier of the generated function, a hash of the JavaScript
public String getExpression()
public Object invokeOn(Object implementation) // runs the call on a Java implementation of the definition

com.vaadin.flow.js.JsDefinitionProxy

// Added
public final class JsDefinitionProxy // internal use only; call Element.executeJs(Class)
public static <T> T create(Class<T> definitionType, SerializableFunction<JsCall, PendingJavaScriptResult> runner)

com.vaadin.flow.dom.Element

// Added
public <T> T executeJs(Class<T> definitionType) // runs the JavaScript declared by the interface, through an implementation of it

com.vaadin.flow.component.Focusable.FocusJs

// Added
public interface FocusJs extends Serializable // nested in Focusable, annotated with @JsDefinition
void focus(@Nullable ObjectNode options)
void blur()

com.vaadin.flow.component.internal.UIInternals.JavaScriptInvocation

// Added
public JavaScriptInvocation(@Nullable JsCall jsCall, String expression, Object... parameters)
public @Nullable JsCall getJsCall() // null when the invocation is plain JavaScript

com.vaadin.flow.internal.ReflectTools

// Added
public static List<Method> getMethodsWithParameterCount(Class<?> cls, String methodName, int parameterCount)

com.vaadin.flow.internal.FrontendUtils

// Added
public static final String JS_DEFINITIONS_FILE_NAME // "vaadin-js-definitions.js"

com.vaadin.flow.shared.JsonConstants

// Added
public static final String UIDL_KEY_JS_FUNCTION // "f", the key naming the function to run

com.vaadin.flow.server.frontend.TaskGenerateJsDefinitions

// Added
public class TaskGenerateJsDefinitions extends AbstractTaskClientGenerator // internal use only
public static List<Class<?>> findMissingFromGeneratedFile(Options options, Collection<Class<?>> definitions)
public static List<Class<?>> updateJsDefinitions(Options options, Collection<Class<?>> definitions)
protected String getFileContent()
protected File getGeneratedFile()
protected boolean shouldGenerate()

com.vaadin.base.devserver.hotswap.impl.JsDefinitionHotswapper

// Added
public class JsDefinitionHotswapper implements VaadinHotswapper // internal use only
public void onClassesChange(HotswapClassEvent event)

Test summary

Declaring and calling JavaScript from Java:

  • Declared expression and call arguments scheduled as one invocation carrying the call
  • Result-returning method answers with the scheduled invocation
  • Refused when the interface is not annotated, or has a method without an expression, an unsupported return type, or a Java body
  • Call run on a Java implementation, with null arguments and thrown exceptions passed through
  • focus/blur dispatched as FocusJs calls, plain executeJs left without a call

UIDL encoding:

  • Expression sent as a constant-pool key, once per session
  • Declared call sent as {"f": <function id>}, carrying neither the expression nor the declaring class
  • Return channels appended after the element when the call is subscribed to
  • MPR push-state fix-up resolved through the constants

Generated definitions file:

  • One function per declared expression, developer-facing names only outside production
  • Rewritten in place adds only what is missing and leaves an unchanged file alone
  • Definitions reported as missing when the file cannot be written or does not carry them

Bundle validation and dev mode:

  • Rebuilt when the bundle's hash for the generated file is wrong or absent
  • Hotswap rewrites the file with the dev server running, warns without it
  • Declared JavaScript part of the dev-loop frontend fingerprint

Client:

  • Bundled function applied to the element with the call arguments, result sent to the success channel
  • Unknown function id or mismatched argument count reported on the error channel, function not run
  • String constant run as an expression even when it looks like a function id; unknown constant runs nothing
  • Constants imported as the message arrives; a repeated key with the same value accepted, a different value refused

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 #25734
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 #25734
@totally-not-ai

Copy link
Copy Markdown
Contributor Author

Type of change

Prototype, draft on purpose, and an alternative to its own base rather
than an increment on it: #25747 is the command shape, this is the invoker
shape, both wiring the same Focusable and both verified the same way.

How to test

mvn test -pl flow-server -Dtest=FocusableTest,ElementTest
mvn test -pl flow-server     # whole module: 5284 tests, 0 failures

The three generated scripts (focus, focus with options, blur) are byte
identical to main after being moved into FocusJs, so FocusBlurIT
still guards the browser behaviour.

API changes relative to #25747

// Added
public @interface JsExpression { String value(); }
public record JsInvokerCall(Class<?> invokerType, String methodName, List<Object> arguments) implements JsCommand
public Object JsInvokerCall.invokeOn(Object implementation)
public interface FocusJs extends Serializable   // focus(), focus(ObjectNode), blur()
public <T> T Element.getJsInvoker(Class<T> invokerType)

// Removed
public record FocusCommand(List<FocusOption> options)
public record BlurCommand()

JsCommand, Element.executeJs(JsCommand) and
JavaScriptInvocation.getCommand() come from the base branch and are
unchanged — an invoker call is just another command, which is why the
queue contract, the ordering and the unhandled-JavaScript path are the
same in both prototypes.

The two shapes, side by side

command object (#25747) invoker interface (this)
Call site executeJs(new FocusCommand(options)) getJsInvoker(FocusJs.class).focus(json)
JS lives in one record per operation one constant per method, on one interface
Identity for a driver the Java type interface + method name + arity
Driver integration pattern switch on the type implement the interface, call.invokeOn(impl)
Arguments a driver sees server-side values (PreventScroll.ENABLED) wire parameters ({"preventScroll":true})
Grouping one type per operation any number of operations per interface
Overloads distinct types resolved by name and arity here; needs parameter types to be exact
Path to CSP / #10759 none this is its server half
Cost a record a proxy per call, an annotation, reflection

Two things that only showed up once both existed:

  • The invoker loses the server-side arguments. A command is a value
    that renders to JavaScript, so it can hold FocusOption values and
    build the JSON at scheduling time. An invoker call is the client
    call, so its arguments have to be encodable for the wire — a driver
    reads {"preventScroll":true}. For focus that is survivable; for an
    operation whose Java arguments are not the wire arguments it is a real
    loss, unless the interface method is allowed to take server-side types
    and declare a conversion.
  • The branching moves back to the caller. Because an expression is a
    constant per method, Focusable.focus(FocusOption...) has to pick
    between focus() and focus(json), which the command shape had
    absorbed.

In exchange, invokeOn is the nicest consumer shape either prototype
produced: the driver writes a normal Java class implementing FocusJs
and never matches anything.

What is still missing for #10759

This is only the server half. CSP compatibility needs the client
registry (window.Flow.jsInvokers["com.acme.FocusJs"].focus), the
build-time collection of the annotated expressions into the bundle, and
an invocation on the wire that carries interface, method and arguments
instead of an expression. Until then the expression is still sent and
evaluated exactly as today. The server-side API above does not change
when that lands — which is the argument for choosing the shape now.

On the trigger/action prototype

It already answers the question for its own family, and it answers it
the same way: Triggers.addArmingListener exposes the armed Trigger
and Action objects, the driver keeps a Map<Class<? extends Action>, ActionSimulator<?>>, and Action.Input#evaluate(eventData) reproduces
on the server what the client would have computed. The structure stays
on the server and the JavaScript is a rendering of it; nothing is
recognised by its text.

Both prototypes here apply that to one-shot executeJs, which is the
part with no server-side structure today. The invoker shape lands
closest to it: a registry keyed by invoker interface, holding an
implementation of that interface, is the same registry keyed by action
type holding an ActionSimulator.

Open questions

  1. Command or invoker — or both, with JsCommand as the umbrella they
    already share.
  2. If invoker: may a method take server-side types (with a declared
    conversion), or are arguments always wire values?
  3. If invoker: overload resolution by parameter types, and where the
    proxy is cached.
  4. Should Page get the same entry point for UI-level scripts?

@Artur- @mcollovati @Legioth — the two shapes are now the same feature
twice, so the diffs are directly comparable.

@github-actions

github-actions Bot commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Test Results

 1 459 files  + 4   1 543 suites  +4   1h 34m 57s ⏱️ + 3m 9s
11 995 tests +56  11 927 ✅ +56  68 💤 ±0  0 ❌ ±0 
12 313 runs  +56  12 245 ✅ +56  68 💤 ±0  0 ❌ ±0 

Results for commit 39953e1. ± Comparison against base commit ca64e70.

♻️ This comment has been updated with latest results.

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 #25734
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.
@totally-not-ai totally-not-ai Bot changed the title feat: invoke focus and blur through a JS invoker interface feat: run declared JavaScript without compiling it in the browser Sep 16, 2026
@totally-not-ai
totally-not-ai Bot changed the base branch from feat/js-command-seam-for-focusable to main September 16, 2026 07:33
@totally-not-ai

Copy link
Copy Markdown
Contributor Author

Type of change

Prototype, still a draft, but now the whole chain rather than the server
half: server, protocol, bundle generation, client execution. The command
alternative (#25747) is dropped and closed, so this is the only branch.

How to test

mvn test -pl flow-server            # 5296 tests
mvn test -pl flow-build-tools       # 955 tests
mvn test -pl vaadin-dev-server      # 355 tests
cd flow-client && npm test          # 718 tests, 3 of them new
mvn verify -pl flow-tests/test-root-context -Dit.test=FocusBlurIT

FocusBlurIT is the end-to-end case: it drives server-initiated focus
and blur in Chrome and asserts that both events still report
isFromClient() == false.

What the chain looks like now

The response for Focusable.focus(), captured from the browser:

"execute":[[{"@v-node":9},{"invoker":"com.vaadin.flow.component.FocusJs","method":"focus/0","arguments":0}]]

No JavaScript is sent. The client takes the element (@v-node) as the
this of the call and runs
window.Vaadin.Flow.jsInvokers["com.vaadin.flow.component.FocusJs"]["focus/0"],
which the build generated into vaadin-js-invokers.js from the
@JsExpression constants of FocusJs.

Measured in the browser by counting every call of Function during that
round trip: the focus and blur calls compile nothing. The only two
compilations are the DOM event expressions coming back —
return (event.target._nextFocusIsFromClient) and its blur counterpart.

Two things the end-to-end run turned up

  • The dev mode class finder has to know the annotation. Without
    @JsInvoker in DevModeStartupListener's @HandlesTypes, the Vite
    dev server refuses to start (Unexpected class name … the class finder instance is not aware of this class).
  • A bundle built without the declared JavaScript has to be rebuilt.
    Otherwise the prepackaged dev bundle is used as-is, the registry is
    empty, and every call fails at runtime. BundleValidationUtil now
    compares the generated file the way it compares the commercial banner,
    which costs one bundle rebuild on the first start after this lands.

How far this gets CSP

Not all the way, and the measurement says exactly how far. Served with
script-src 'self' 'unsafe-inline' — no unsafe-eval — a Flow
application does not start at all today:

EvalError: Evaluating a string as JavaScript violates the following
Content Security Policy directive because 'unsafe-eval' is not an
allowed source of script: script-src 'self' 'unsafe-inline'

That is the bootstrap, not this change: dependency loading evaluates
return window.Vaadin.Flow.loadOnDemand('…'), and every DOM event
expression is compiled per event. Both are named in #10759 and both fit
the same treatment. This PR makes the one-shot executeJs channel do
without compilation and proves the mechanism; the remaining two decide
whether an application can actually run under a strict policy, and are
the natural next PRs.

For a driver that can not run JavaScript

The queued invocation carries the call, so the browserless case is a
pattern match rather than a substring match on generated script, and
call.invokeOn(implementation) lets Java dispatch it onto an
implementation of the same interface — see
vaadin/browserless-test#221. FocusableTest has that loop as a test,
over a queue of focus → application executeJs → blur.

Open questions

  1. Scope: do dependency loading and DOM event expressions belong here or
    in follow-ups?
  2. Overloads are resolved by name and argument count; parameter types
    would be exact but need the signature on the wire.
  3. Should Page get the same entry point for UI-level scripts?
  4. The proxy is created per call; caching it per element and interface is
    left out on purpose.

@Artur- @mcollovati @Legioth

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.
@github-actions

github-actions Bot commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

📦 Snapshot published

25.4.js-invoker-seam-for-focusable-SNAPSHOT — built from 39953e1 (run).

Built without running tests. Look at the checks on this pull request before relying on it.

Every new commit on this branch republishes it while the snapshot build label is there. Add -U to pick the newest build up.

How to use it
<repository>
  <id>vaadin-snapshots</id>
  <url>https://maven.vaadin.com/vaadin-prereleases</url>
  <snapshots><enabled>true</enabled></snapshots>
</repository>

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>com.vaadin</groupId>
      <artifactId>flow-bom</artifactId>
      <version>25.4.js-invoker-seam-for-focusable-SNAPSHOT</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

Import flow-bom before vaadin-bom for it to win over the platform's Flow version.

Comment thread flow-client/src/main/frontend/internal/client/flow/ExecuteJavaScriptProcessor.ts Outdated
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.
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.
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.
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.
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.
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.
Comment thread flow-server/src/main/java/com/vaadin/flow/component/FocusJs.java Outdated
Comment thread flow-server/src/main/java/com/vaadin/flow/component/FocusJs.java Outdated
Comment thread flow-server/src/main/java/com/vaadin/flow/dom/JsExpression.java Outdated
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.
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.
@Artur-

Artur- commented Sep 18, 2026

Copy link
Copy Markdown
Member

Should this same PR handle Page level executions also?

@Legioth

Legioth commented Sep 18, 2026

Copy link
Copy Markdown
Member

I think this PR is already big enough. We can do Page in a separate PR on top. There are also some other enhancements on my mind that could be added on top once the base functionality is merged.

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.
Collection<Class<?>> definitions) {
String generated = readGeneratedFile(options);
String content = withMissingEntries(generated, definitions,
!options.isProductionMode());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no point in chrcking production mode in a dev only class

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right - findMissingFromGeneratedFile and updateJsDefinitions are only called while the application runs, so they render with the names unconditionally now and the mode is asked about only where a build decides what it writes. The cases for those two ran as a production build, which is the one mode they never run in, so they run as a development build now.

* 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<String> header(boolean withNames) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

”header” what - methods should have a verb

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed to renderHeader, next to the renderFileContent and renderDefinitionLines it belongs with.


@Override
protected boolean shouldGenerate() {
return options.getClassFinder() != null;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When would classfinder be null?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would not be - a build that scans for anything has a class finder, and the file is written whenever the frontend is generated. The check is gone.

if (command.length > 0 && command[0] === 'window.location.reload();') {
const runs = whatInvocationRuns(
command,
(valueMap.constants ?? {}) as Record<string, unknown>,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does this look in valueMap?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should not constants first be put in the pool?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It no longer does - see the reply on the next line.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They are now: the constants of a message go into the pool as it arrives, before anything decides what to do with the message, and what an invocation runs is read from the pool alone.

That needed one thing: a message that is queued here is read again when it is handled, so the same constants are imported twice. A key is a hash of the value it names, so the second import is the same value, and ConstantPool.importFromJson takes a key it already holds instead of asserting on it.

* @param constantPool - what earlier messages put in the pool
* @returns what to run, or `null` when nothing is named
*/
export function whatInvocationRuns(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missign a verb

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed to resolveWhatRuns, and it takes the constant pool alone now that the constants are in it before anything reads them.

* 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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verb in method name here and many other places

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed the ones this change added: getDeclaredJavaScript for the registries the bundle populates and getNameOf for what a message calls a function by. The others in the file - findDeclaredFunction, reportThroughChannel, invokeFromBundle - already read as verbs.

* @return the function identifier, not <code>null</code>
*/
public static String functionId(String expression, int argumentCount) {
return StringUtil.getHash(argumentCount + ":" + expression,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the argument count relevant here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is, because the parameter list of the generated function is made of it: the same expression at one argument and at two is async function ($0) and async function ($0, $1), two different functions. Hashing the expression alone would give them one identifier, and whichever the build wrote last would be what both calls run.

It is also what the client reads the invocation with - the function takes the arguments, so its arity says where the element is - so two arities have to stay two functions.

…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.
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.
Comment thread flow-client/src/main/frontend/internal/client/communication/MessageHandler.ts Outdated
Comment thread flow-client/src/main/frontend/internal/client/flow/ConstantPool.ts
Comment thread flow-client/src/test/frontend/internal/client/flow/ConstantPoolTests.ts Outdated
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.
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.
Artur-
Artur- previously approved these changes Sep 21, 2026
@Artur-

Artur- commented Sep 21, 2026

Copy link
Copy Markdown
Member

@claude review

@claude

claude Bot commented Sep 21, 2026

Copy link
Copy Markdown

Claude finished @Artur-'s task in 5m 20s —— View job


Code review

I read the full change end-to-end (server js package, Element/UidlWriter/UidlRequestHandler, the client ExecuteJavaScriptProcessor/MessageHandler/ConstantPool, the build task, and the hotswap/validation paths). This is already approved and has been through a long, careful review cycle — the design holds together and the wire/format story is coherent. Below are the few things I'd still raise; none block, one is worth a conscious decision.

  • Read the diff
  • Review server-side Java
  • Review client-side TypeScript
  • Review build tooling & hotswap
  • Post review

Worth a decision — session constant pool now retains every distinct expression

With this change, executeJs(String, …) expressions go through the constant pool (UidlWriter.constantOf) instead of being sent inline. On the server, ConstantPool.knownValues (flow-server/.../internal/ConstantPool.java:36) is a Set that is never evicted for the life of the session, and the client ConstantPool (ConstantPool.ts) is likewise never cleared (not even on prepareForResync).

For the common case where an expression is reused this is a clear win (sent once, named many times). But code that builds a unique expression string per call — e.g. executeJs("doThing('" + userValue + "')") — now leaks one permanent pool entry per call on both server and client, where previously the string was transient. It's an anti-pattern (the parameterized $0 form is the right way), but the wire change quietly turns it into unbounded per-session memory growth. Before merging the follow-ups that build on this, it's worth either accepting this explicitly or noting it in the executeJs javadoc so the cost is discoverable. I confirmed there is no resync correctness problem here (both pools survive resync, so "known on server" always implies "present on client").

Minor

  • ConstantPool.importFromJson evaluates the assert argument unconditionallyConstantPool.ts:44-48 computes JSON.stringify(existing) === JSON.stringify(value) as an argument to assert(...). If assert is a runtime call (not stripped), this double-stringify runs on every duplicate-key import — and duplicates are now the normal path, since a queued/re-sent message re-imports its constants. Since the key is already a hash of the value, the comparison can essentially never fail; consider gating it behind a dev-only check or this.#constants.has(key) short-circuit that avoids the stringify in production.

  • FUNCTION_ID shape discrimination (ExecuteJavaScriptProcessor.ts:75) — an ordinary executeJs(String) whose expression is exactly 64 lowercase hex chars would be misclassified as a function id and reported as "not in the bundle" rather than run. This is an intentional, documented trade-off (the constant is a bare string on the wire, per Artur's request) and practically unreachable, so I'd only suggest keeping the explanatory comment — no code change.

  • JsDefinitionProxy.checkMethods (JsDefinitionProxy.java:104-111) — a method that both lacks @JsExpression and has an illegal return type is added to both undeclared and unanswerable. Only the first exception is thrown, so it's harmless, but you could continue after the undeclared add to keep the buckets disjoint.

Things I checked and found correct

  • Parameter layout round-trips: server appends […args, element(, success, error)] (Element.withElementAsLastParameter + UidlWriter.encodeJsCall) and the client reads fn.length back off it, with the afterTheArguments ∈ {1,3} guard as a second line of defense behind the arity-in-the-hash lookup.
  • JsCall.functionId hashing arity together with the expression is necessary — same expression at different arities is genuinely two functions — and matches what TaskGenerateJsDefinitions registers.
  • calculateHash/vite both normalize \r\n, so bundle validation of vaadin-js-definitions.js is stable cross-platform.
  • The subscribed-call error channel is reported through even on the "missing function"/"bad arity" paths, so a PendingJavaScriptResult can't hang.
  • Hotswap split is clean: the file format lives only in TaskGenerateJsDefinitions, and the hotswapper only decides reachability (dev server → rewrite, otherwise → warn).

Nice work — the invokeOn driver seam and the "no Java names in a production bundle" property are the parts I'd call out as especially well done.
· feat/js-invoker-seam-for-focusable

@Artur-

Artur- commented Sep 21, 2026

Copy link
Copy Markdown
Member

FUNCTION_ID shape discrimination (ExecuteJavaScriptProcessor.ts:75) — an ordinary executeJs(String) whose expression is exactly 64 lowercase hex chars would be misclassified as a function id and reported as "not in the bundle" rather than run. This is an intentional, documented trade-off (the constant is a bare string on the wire, per Artur's request) and practically unreachable, so I'd only suggest keeping the explanatory comment — no code change.

Let's add "f": "id" instead of "id" or something similar so we don't have a collision risk

@Artur-

Artur- commented Sep 21, 2026

Copy link
Copy Markdown
Member

JsDefinitionProxy.checkMethods (JsDefinitionProxy.java:104-111) — a method that both lacks @JsExpression and has an illegal return type is added to both undeclared and unanswerable. Only the first exception is thrown, so it's harmless, but you could continue after the undeclared add to keep the buckets disjoint.

Let's fix this for clarity

…ke 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": "<identifier>"}`, 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.
…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.
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.
… 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.
@sonarqubecloud

Copy link
Copy Markdown

@Artur-
Artur- added this pull request to the merge queue Sep 21, 2026
Merged via the queue into main with commit 3179a85 Sep 21, 2026
42 checks passed
@Artur-
Artur- deleted the feat/js-invoker-seam-for-focusable branch September 21, 2026 18:47
vaadin-bot added a commit to vaadin/docs that referenced this pull request Sep 21, 2026
Add a section to the Calling JavaScript page explaining @JsDefinition
and @JsExpression, and Element.executeJs(Class), which runs JavaScript
that the build collects into the bundle instead of compiling an
expression in the browser, so the call works under a content security
policy without unsafe-eval. Cross-reference it from the existing
executeJs(String, Object...) section, which had no mention of that
limitation.

Documents vaadin/flow#25749 (`39953e133526e0b268f7c24fd27bbc760bec34ea`).
@github-actions

Copy link
Copy Markdown
Contributor

Pull request created: #6113

Generated by Documentation Bot · agent · 74.6 AIC · ⌖ 5.68 AIC · ⊞ 11.7K

@github-actions

Copy link
Copy Markdown
Contributor

Documentation Bot: Draft documentation pull request for this change: vaadin/docs#6113

Files updated:

  • articles/flow/component-internals/element-api/calling-javascript.adoc

It was written from the state of this pull request as you see it now. Please review it and mark it ready for review.

Generated by Documentation Bot for #25749 · agent · 74.6 AIC · ⌖ 5.68 AIC · ⊞ 11.7K ·

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants