Let executeJs expressions use values imported from JS modules - #25240
Draft
totally-not-ai[bot] wants to merge 11 commits into
Draft
Let executeJs expressions use values imported from JS modules#25240totally-not-ai[bot] wants to merge 11 commits into
totally-not-ai[bot] wants to merge 11 commits into
Conversation
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.
Contributor
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.
Artur-
marked this pull request as draft
August 18, 2026 04:01
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Why
A JavaScript expression sent from the server with
executeJsis evaluated in the global scope, so it cannot useimportto reach values exported by a JS module. The usual workaround is to hand-write a module whose only job is to republish those values onwindow.This branch removes that workaround, and along the way finishes the runtime-ES-module support on
@JavaScriptthat the same code paths touch.Importing values for
executeJs@JsModulegains two optional attributes,importsandimportAll. A class that sets either of them declares what to import rather than a side-effect module, andJsImports.of(TheClass.class)hands those values to an expression as an ordinary parameter:Because every reference is its own parameter, two modules exporting the same name never collide, so no renaming syntax is needed.
How it works:
AbstractUpdateImports, which publishes the values in a client-side registry (window.Vaadin.Flow.imports). The modules therefore stay out of the eager bundle.UIInternalsrequests that chunk as a dynamic import dependency whenever it sees aJsImportsparameter (including ones captured by aJsFunction). 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.JacksonCodecencodes the reference as@v-importscarrying the chunk id (not the Java class name), andClientJsonCodecresolves it against the registry.FrontendClassVisitor/FrontendDependenciesandFullDependenciesScanner), and a declared module is not also emitted as a side-effect import.BuildFrontendUtilcounts 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,
importAllcombined with other declarations on the class,importsandimportAllon 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)@JavaScriptgains atypeattribute, andPagegains anaddJavaScript(String, LoadMode, JavaScript.Type)overload that replaces the now-deprecatedaddJsModule(String)(the new overload also accepts aLoadMode).Type.MODULErenders 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.INLINEis rejected forType.MODULE, since the browser cannot be given a module's contents without losing the module's identity.Fixes folded in
developmentOnlyis now honored for runtime JavaScript:@JavaScript(value = "devtools.js", type = MODULE, developmentOnly = true)was loaded in production.addExternalDependenciesnow skips development-only values in production mode, which also covers external@JavaScriptand external@JsModulevalues where the flag was ignored even beforetype = MODULEexisted.developmentOnlyis now carried onJsImportsDataand 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.getUniqueEs6ImportPaths, dropping the theme-translated and transitively imported files that a plain@JsModuleemits. 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.@JsModuledeclaring 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/DependencyListencoding, runtime dependency handling (RuntimeJavaScriptDependencyTest),ClientJsonCodecdecoding of@v-importsand the unknown@v-guard, and commercial-product detection through JS module imports. Integration tests cover named imports, namespace imports, deferred use (JsImportsIT) and runtimetype = MODULEscripts (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
renderandhtml— those are exports oflit-html, and a server-sent expression cannotimportthem.Declaring the imports on a small class makes them available as an ordinary
executeJsparameter:lit-htmlis 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
com.vaadin.flow.component.dependency.JsModule
com.vaadin.flow.component.page.Page
com.vaadin.flow.dom.JsImports
com.vaadin.flow.server.frontend.scanner.JsImportsData
com.vaadin.flow.server.frontend.scanner.FrontendDependenciesScanner
com.vaadin.flow.server.frontend.scanner.FrontendDependencies