Skip to content

Let executeJs expressions use values imported from JS modules - #25240

Draft
totally-not-ai[bot] wants to merge 11 commits into
mainfrom
feature/js-imports
Draft

Let executeJs expressions use values imported from JS modules#25240
totally-not-ai[bot] wants to merge 11 commits into
mainfrom
feature/js-imports

Conversation

@totally-not-ai

@totally-not-ai totally-not-ai Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Why

A JavaScript expression sent from the server with executeJs is evaluated in the global scope, so it cannot use import to reach values exported by a JS module. The usual workaround is to hand-write a module whose only job is to republish those values on window.

This branch removes that workaround, and along the way finishes the runtime-ES-module support on @JavaScript that the same code paths touch.

Importing values for executeJs

@JsModule gains two optional attributes, imports and importAll. A class that sets either of them declares what to import rather than a side-effect module, and JsImports.of(TheClass.class) hands those values to an expression as an ordinary parameter:

@JsModule(value = "lit-html", imports = { "render", "html" })
final class LitImports {
}

element.executeJs("$0.render($0.html`<div>${$1}</div>`, this)",
        JsImports.of(LitImports.class), "Lit");

Because every reference is its own parameter, two modules exporting the same name never collide, so no renaming syntax is needed.

How it works:

  • Each declaring class gets its own lazily loaded chunk, generated by AbstractUpdateImports, which publishes the values in a client-side registry (window.Vaadin.Flow.imports). The modules therefore stay out of the eager bundle.
  • UIInternals requests that chunk as a dynamic import dependency whenever it sees a JsImports parameter (including ones captured by a JsFunction). The client loads a response's dependencies before running its JavaScript invocations, so the values are always registered by the time the expression is evaluated, and already requested chunks are dropped from the dependency list so repeated use loads the chunk once.
  • JacksonCodec encodes the reference as @v-imports carrying the chunk id (not the Java class name), and ClientJsonCodec resolves it against the registry.
  • The declarations are wired through both dependency scanners (FrontendClassVisitor/FrontendDependencies and FullDependenciesScanner), and a declared module is not also emitted as a side-effect import.
  • BuildFrontendUtil counts modules reached only through @JsModule(imports = ...) when detecting commercial products.

Conflicting declarations fail the build rather than producing a broken chunk: the same name imported from two modules, importAll combined with other declarations on the class, imports and importAll on the same annotation, names that are not valid JavaScript identifiers, and imports declared from an external URL (such a module is not in the bundle, so the import could never be resolved).

Runtime ES modules via @JavaScript(type = MODULE)

@JavaScript gains a type attribute, and Page gains an addJavaScript(String, LoadMode, JavaScript.Type) overload that replaces the now-deprecated addJsModule(String) (the new overload also accepts a LoadMode). Type.MODULE renders a <script type="module">, is never bundled, and accepts bare relative URLs, which are normalized against the servlet context root with the same resolver the annotation path uses. LoadMode.INLINE is rejected for Type.MODULE, since the browser cannot be given a module's contents without losing the module's identity.

Fixes folded in

  • developmentOnly is now honored for runtime JavaScript: @JavaScript(value = "devtools.js", type = MODULE, developmentOnly = true) was loaded in production. addExternalDependencies now skips development-only values in production mode, which also covers external @JavaScript and external @JsModule values where the flag was ignored even before type = MODULE existed.
  • developmentOnly is now carried on JsImportsData and honored by the chunk generator, so such a declaration no longer pulls its module into the production bundle or counts as a used npm package.
  • Resolving a declared module kept only the first path from getUniqueEs6ImportPaths, dropping the theme-translated and transitively imported files that a plain @JsModule emits. They are now emitted as side-effect imports in the same chunk, skipping paths already bound by name, so both spellings put the same files into the bundle.
  • Page.addJavaScript(url, loadMode, MODULE) passed a bare relative URL through untouched, so the browser requested it relative to the current route and got a 404. It is now normalized, and a URL that cannot be normalized is rejected instead of silently broken.
  • An external @JsModule declaring imports was still added to the page as a side-effect module script, running the module twice; it is now filtered out at runtime.

Testing

Unit tests for chunk generation and validation (AbstractUpdateImportsTest), both scanners, JsImports (including equality), JacksonCodec/DependencyList encoding, runtime dependency handling (RuntimeJavaScriptDependencyTest), ClientJsonCodec decoding of @v-imports and the unknown @v- guard, and commercial-product detection through JS module imports. Integration tests cover named imports, namespace imports, deferred use (JsImportsIT) and runtime type = MODULE scripts (RuntimeJavaScriptModuleIT).

Use case

An application has a component that draws a small sparkline chart. The markup is easiest to express as a Lit template, but the developer does not want to add a hand-written JS wrapper file to the project just so the server can call render and html — those are exports of lit-html, and a server-sent expression cannot import them.

Declaring the imports on a small class makes them available as an ordinary executeJs parameter:

@JsModule(value = "lit-html", imports = { "render", "html" })
final class LitImports {
}

@Route("dashboard")
public class SparklineCard extends Div {

    public void setValues(List<Integer> values) {
        String points = values.stream().map(String::valueOf)
                .collect(Collectors.joining(" "));
        getElement().executeJs("""
                $0.render(
                    $0.html`<svg viewBox="0 0 100 20"><polyline points=${$1}/></svg>`,
                    this)
                """, JsImports.of(LitImports.class), points);
    }
}

lit-html is loaded on demand in its own chunk the first time the expression is sent, so it does not weigh down the eager bundle of pages that never show the card.

API Changes

com.vaadin.flow.component.dependency.JavaScript

// Added
public enum Type // nested in the annotation; values SCRIPT, MODULE
Type type() default Type.SCRIPT // the kind of <script> tag to render

com.vaadin.flow.component.dependency.JsModule

// Added
String[] imports() default {} // names to import from the module and publish for the annotated class
boolean importAll() default false // import the whole module namespace instead of individual names

com.vaadin.flow.component.page.Page

// Added
public void addJavaScript(String url, LoadMode loadMode, JavaScript.Type type)

// Changed
- public void addJsModule(String url)
+ @Deprecated(since = "25.3") public void addJsModule(String url) // use addJavaScript(String, LoadMode, JavaScript.Type) with Type.MODULE

com.vaadin.flow.dom.JsImports

// Added
public final class JsImports implements Serializable
public static JsImports of(Class<?> declaringClass) // throws IllegalArgumentException if the class declares no JS module imports
public String getDeclaringClassName()
public String getChunkId() // internal use only
public boolean equals(Object obj)
public int hashCode()
public String toString()

com.vaadin.flow.server.frontend.scanner.JsImportsData

// Added
public class JsImportsData implements Serializable // internal use only
public JsImportsData(String className, String module, List<String> names, boolean importAll, boolean developmentOnly)
public String getClassName()
public String getModule()
public List<String> getNames()
public boolean isImportAll()
public boolean isDevelopmentOnly()
public boolean equals(Object obj)
public int hashCode()
public String toString()

com.vaadin.flow.server.frontend.scanner.FrontendDependenciesScanner

// Added
default List<JsImportsData> getJsImports() // returns List.of() by default, so implementations need no change

com.vaadin.flow.server.frontend.scanner.FrontendDependencies

// Added
public List<JsImportsData> getJsImports() // sorted by class name for stable chunk generation

Artur- and others added 8 commits June 16, 2026 13:53
Lets a @javascript annotation render as a <script type="module"> tag
instead of a classic <script>, so hand-authored or CDN-hosted ES
modules can be loaded at runtime through annotations without going
through Vite. For build-time bundled ES modules @jsmodule remains the
right tool.

The new @JavaScript.Type enum has values SCRIPT (default, current
behavior) and MODULE. The annotation gains a type() attribute that
selects between them.

To make @javascript the unified entry point on the programmatic side
as well, this commit also:
- adds a new Page.addJavaScript(String url, LoadMode loadMode,
  JavaScript.Type type) overload that handles both classic <script>
  and <script type="module"> tags, with full LoadMode support for
  both;
- delegates the existing addJavaScript(String, LoadMode) and
  addJavaScript(String) overloads to the new method with
  Type.SCRIPT;
- deprecates Page.addJsModule(String) — recommend
  addJavaScript(url, loadMode, Type.MODULE) instead. The deprecated
  method keeps working for backwards compatibility.

UIInternals.addExternalDependencies routes both @javascript runtime
values and external @jsmodule values through the new addJavaScript
overload. @javascript values pass js.loadMode() and js.type()
straight through, so type=MODULE supports LAZY and INLINE load modes
just like type=SCRIPT.

FrontendClassVisitor.JSAnnotationVisitor reads the type enum via a
new visitEnum override and skips MODULE-typed values from the bundle
imports collection. The type attribute does not exist on @jsmodule,
so visitEnum is a no-op for it.

Existing @javascript usages keep their behavior: bare relative values
default to type=SCRIPT and continue to bundle (legacy interpretation),
external URLs continue to render as runtime <script> tags.
…e-attribute

# Conflicts:
#	flow-server/src/main/java/com/vaadin/flow/component/page/Page.java
LoadMode.INLINE was documented as supported for type=MODULE, but the
client rejects it: DependencyLoader throws "Inline load mode is not
supported for JsModule." for JS_MODULE dependencies. Inlining a module
would mean handing the browser the contents without the module's
identity, so instead of adding client-side support the combination is
now rejected up front, in Page.addJavaScript and — with the offending
component class in the message — in UIInternals.addExternalDependencies.
The Javadoc on Page.addJavaScript, JavaScript.Type.MODULE and
JavaScript.loadMode is corrected accordingly.

Also fixes two problems found while testing this:

- FullDependenciesScanner, the reflection based scanner used in dev
  mode, did not skip type=MODULE values, so a bare relative value ended
  up in generated-flow-imports.js and the Vite dev bundle build failed
  with "failed to resolve import". Only the bytecode
  FrontendClassVisitor path was filtering them out.
- UIInternals routed external @javascript values through
  FrontendDependencyUrlResolver.resolveToContextRoot, whose traversal
  check runs before the external URL passthrough. An external URL
  containing '..' was therefore silently dropped, where it used to
  work. Only bare relative values are normalized now.

Tests: Page level type mapping and the INLINE rejection in
DependencyListTest, annotation routing in the new
RuntimeJavaScriptDependencyTest, dev mode bundle exclusion in
FullDependenciesScannerTest, and RuntimeJavaScriptModuleIT verifying in
a browser that eager and lazy modules reach the page as
<script type="module"> and are evaluated as modules.

Minor: @deprecated(since = "25.3") on Page.addJsModule to match the
convention used elsewhere, and a broader class Javadoc on
FrontendDependencyUrlResolver now that it also normalizes @javascript.
warnForUnavailableBundledDependencies collected every non-external
@javascript value, including type=MODULE ones. Those are deliberately
kept out of the bundle by both scanners, so in production mode a
component with a bare relative type=MODULE value logged an error saying
the file "was not included when creating the production bundle" and that
"the component will not work properly" — a false positive for a value
that works fine as a runtime <script type="module">. type=MODULE values
are now filtered out before the bundle check.

Also:

- addExternalDependencies checks the unsupported INLINE + MODULE
  combination before normalizing the value, so it is reported for values
  that normalization would reject (e.g. a path traversal) instead of
  being silently skipped.
- The @javascript class Javadoc described only the legacy bundling
  behavior and never mentioned type(); it now covers the runtime MODULE
  path and links the new Page.addJavaScript overload. Fixes the two
  'javscript' typos on the same lines.
- Sonar: use Stream.toList() in RuntimeJavaScriptDependencyTest and hoist
  getPage() out of the assertThrows lambda in DependencyListTest.

The production-mode tests capture the log while scanning against a
pretend bundle that contains neither test file, and include a type=SCRIPT
control so the "no error logged" assertion cannot pass vacuously.
A JavaScript expression sent from the server is evaluated in the global
scope, so it cannot use import to reach values exported by a JS module.
Working around that meant writing a module that republishes the values on
window just to make them reachable.

@jsmodule gains optional imports and importAll attributes. A class that
sets either of them declares which values to import; the values are
published in a client-side registry by a chunk generated for that class,
and JsImports.of(TheClass.class) hands them to an expression as an
ordinary executeJs parameter:

  @jsmodule(value = "lit-html", imports = { "render", "html" })
  final class LitImports {}

  element.executeJs("$0.render($0.html`<div>${$1}</div>`, this)",
          JsImports.of(LitImports.class), "Lit");

Since every reference is its own parameter, two modules exporting the
same name never collide, so no renaming syntax is needed.

Each declaring class gets its own lazily loaded chunk, so the modules
stay out of the eager bundle. UIInternals requests that chunk as a
dynamic import dependency when it sees a JsImports parameter, and the
client loads a response's dependencies before running its JavaScript
invocations, so the values are always registered by the time the
expression is evaluated. The dependency list drops already requested
chunks, so repeated use loads the chunk once.

The declarations are wired through both dependency scanners, and a
declared module is not emitted as a side-effect import. Conflicting
declarations on one class fail the build: a name imported from two
modules, importAll combined with other imports, and names that are not
valid JavaScript identifiers.

Part of #5094
…duction

A @javascript value with type=MODULE is never bundled; it is added to the
page at runtime instead. Nothing on that path looked at developmentOnly,
so @javascript(value = "devtools.js", type = MODULE, developmentOnly =
true) was loaded in production. As a bare relative value used to be
bundled, and bundling does honour the flag, this was a regression of a
documented attribute for the new combination.

addExternalDependencies now skips developmentOnly values when the session
runs in production mode. This also covers external @javascript and
external @jsmodule values, where the flag was ignored already before
type=MODULE existed.

Nothing changes at build time: a type=MODULE value stays out of the
bundle either way, so which of the two scanner targets it would have gone
to makes no difference.

Applies to the @javascript type attribute rather than to JS module
imports, so it can be cherry-picked to the branch of #24239.
…ports

Two gaps in @jsmodule(imports = ...) / (importAll = true):

developmentOnly was read but never acted on, so a declaration marked that
way got a chunk and pulled its module into the production bundle. The flag
is now carried on JsImportsData and such declarations are left out of a
production build, matching how a plain @jsmodule marked that way only
reaches the development bundle. A class whose declarations are all
development only therefore gets no chunk in production, and its module is
no longer counted as a used npm package.

Resolving a declared module kept only the first path getUniqueEs6ImportPaths
returns, while a plain @jsmodule emits an import line for every one of
them. The dropped paths are the theme translated and transitively imported
files that handleImports appends, so the same module got different
treatment depending on which spelling was used, and those files were
missing from the generated output that the bundle check hashes. They are
now emitted as side-effect imports in the same chunk, skipping any path
that is already bound by name.
@totally-not-ai totally-not-ai Bot changed the title feature/js imports Let executeJs expressions use values imported from JS modules, and add a type attribute to @JavaScript for runtime ES modules Aug 17, 2026
@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Test Results

 1 377 files  + 5   1 378 suites  +5   1h 28m 40s ⏱️ + 1m 31s
10 524 tests +80  10 457 ✅ +80  67 💤 ±0  0 ❌ ±0 
10 843 runs  +80  10 775 ✅ +80  68 💤 ±0  0 ❌ ±0 

Results for commit 9eb5cb1. ± Comparison against base commit e107185.

♻️ This comment has been updated with latest results.

Raises coverage on the new code above the Sonar gate, and closes the three
gaps it pointed at.

ClientJsonCodec had no test at all. The values it decodes that only a
browser can provide are left to the integration test, but rejecting a
malformed @v-imports value happens before any of that and is worth
asserting, together with the unknown @V- type guard next to it.

JsImports equality is what makes two references to the same declaring
class interchangeable, so give it a test rather than leaving it to the
uses that happen to compare instances.

BuildFrontendUtil counts modules reached only through
@jsmodule(imports = ...) when detecting commercial products. The mocked
scanner returned no declarations, so neither that nor the development only
exclusion was exercised.
…le URLs

Four review findings.

Page.addJavaScript(url, loadMode, MODULE) accepted a bare relative URL and
passed it through untouched, so the browser requested it relative to the
current route and got a 404. The method it deprecates, addJsModule,
rejected such a URL outright. It is now normalized with the same resolver
the annotation path uses, so both spellings mean the same thing, and a URL
that cannot be normalized is rejected rather than silently broken. Only
Type.MODULE is normalized, leaving the pre-existing types untouched.

An external @jsmodule declaring imports was still added to the page as a
side-effect module script, contradicting the documented behaviour and
running the module twice. It is now filtered out at runtime, and declaring
imports from an external URL fails the build: such a module is not in the
bundle, so the import could never be resolved when the bundle is built.

imports and importAll on the same annotation silently discarded the names,
skipping their validation as well. The combination is now rejected, which
also restores JsImportsData's documented contract that the name list is
empty when importAll is set.

Also drops the @SInCE javadoc tags, per CLAUDE.md.
@totally-not-ai totally-not-ai Bot changed the title Let executeJs expressions use values imported from JS modules, and add a type attribute to @JavaScript for runtime ES modules Let executeJs expressions use values imported from JS modules via @JsModule(imports = ...) Aug 17, 2026
@totally-not-ai totally-not-ai Bot changed the title Let executeJs expressions use values imported from JS modules via @JsModule(imports = ...) Let server-sent executeJs expressions use values imported from JS modules Aug 17, 2026
@Artur-
Artur- marked this pull request as draft August 18, 2026 04:01
@totally-not-ai totally-not-ai Bot changed the title Let server-sent executeJs expressions use values imported from JS modules Let executeJs expressions use values imported from JS modules Aug 18, 2026
@sonarqubecloud

Copy link
Copy Markdown

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants