Skip to content

Commit 58e2453

Browse files
committed
feat: add type attribute to @javascript for runtime ES modules
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.
1 parent 3190b0e commit 58e2453

6 files changed

Lines changed: 147 additions & 6 deletions

File tree

flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/scanner/FrontendClassVisitor.java

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ private static final class JSAnnotationVisitor
6969

7070
boolean currentDevOnly = false;
7171
private String currentModule;
72+
private boolean currentTypeIsModule = false;
7273

7374
private LinkedHashSet<String> target;
7475
private LinkedHashSet<String> targetDevelopmentOnly;
@@ -91,10 +92,22 @@ public void visit(String name, Object value) {
9192
}
9293
}
9394

95+
@Override
96+
public void visitEnum(String name, String descriptor, String value) {
97+
// The "type" attribute only exists on @JavaScript; @JsModule has
98+
// no such attribute, so this method is a no-op for it.
99+
if ("type".equals(name) && "MODULE".equals(value)) {
100+
currentTypeIsModule = true;
101+
}
102+
}
103+
94104
@Override
95105
public void visitEnd() {
96106
super.visitEnd();
97-
if (currentModule != null) {
107+
// type=MODULE values are loaded at runtime as <script
108+
// type="module">
109+
// by UIInternals; they must not enter the bundle.
110+
if (currentModule != null && !currentTypeIsModule) {
98111
// This visitor is called also for the $Container annotation
99112
if (currentDevOnly) {
100113
targetDevelopmentOnly.add(currentModule);
@@ -104,6 +117,7 @@ public void visitEnd() {
104117
}
105118
currentModule = null;
106119
currentDevOnly = false;
120+
currentTypeIsModule = false;
107121
}
108122

109123
}

flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/scanner/FrontendDependenciesTest.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
import com.vaadin.flow.server.frontend.scanner.samples.MyUIInitListener;
5252
import com.vaadin.flow.server.frontend.scanner.samples.RouteComponent;
5353
import com.vaadin.flow.server.frontend.scanner.samples.RouteComponentWithMethodReference;
54+
import com.vaadin.flow.server.frontend.scanner.samples.RuntimeJavaScriptComponent;
5455
import com.vaadin.flow.theme.AbstractTheme;
5556
import com.vaadin.flow.theme.Theme;
5657
import com.vaadin.flow.theme.ThemeDefinition;
@@ -98,6 +99,16 @@ void routedComponent_entryPointsAreCollected() {
9899
DepsTests.assertImports(dependencies.getScripts(), "bar.js");
99100
}
100101

102+
@Test
103+
void javaScriptWithTypeModule_excludedFromBundleImports() {
104+
Mockito.when(classFinder.getAnnotatedClasses(Route.class)).thenReturn(
105+
Collections.singleton(RuntimeJavaScriptComponent.class));
106+
FrontendDependencies dependencies = new FrontendDependencies(
107+
classFinder, false, null, true);
108+
109+
DepsTests.assertImports(dependencies.getScripts(), "bundled.js");
110+
}
111+
101112
@Test
102113
void appShellConfigurator_collectedAsEntryPoint()
103114
throws ClassNotFoundException {
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/*
2+
* Copyright 2000-2026 Vaadin Ltd.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
5+
* use this file except in compliance with the License. You may obtain a copy of
6+
* the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations under
14+
* the License.
15+
*/
16+
package com.vaadin.flow.server.frontend.scanner.samples;
17+
18+
import com.vaadin.flow.component.Component;
19+
import com.vaadin.flow.component.dependency.JavaScript;
20+
import com.vaadin.flow.router.Route;
21+
22+
@Route("runtime-js")
23+
@JavaScript("bundled.js")
24+
@JavaScript(value = "module-runtime.js", type = JavaScript.Type.MODULE)
25+
public class RuntimeJavaScriptComponent extends Component {
26+
}

flow-server/src/main/java/com/vaadin/flow/component/dependency/JavaScript.java

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,27 @@
8484
@Repeatable(JavaScript.Container.class)
8585
public @interface JavaScript {
8686

87+
/**
88+
* The kind of {@code <script>} tag to render for the dependency.
89+
*/
90+
enum Type {
91+
/**
92+
* Render a classic {@code <script>} tag. Functions declared in the
93+
* loaded file become available in the global scope.
94+
*/
95+
SCRIPT,
96+
/**
97+
* Render a {@code <script type="module">} tag. The loaded file is
98+
* treated as an ES module: functions and variables declared in it are
99+
* private to the module unless explicitly exported. The file is loaded
100+
* at runtime and is not bundled, even when the URL is a bare relative
101+
* path. Use this for hand-authored or CDN-hosted modules that should
102+
* not go through Vite. For build-time bundled ES modules use
103+
* {@link JsModule} instead.
104+
*/
105+
MODULE
106+
}
107+
87108
/**
88109
* JavaScript file URL to load before using the annotated {@link Component}
89110
* in the browser.
@@ -99,11 +120,28 @@
99120
* frontend directory. Such URLs are not bundled but included into the page
100121
* as standalone scripts in the same way as it's done by
101122
* {@link Page#addJavaScript(String)}.
123+
* <p>
124+
* When {@link #type()} is {@link Type#MODULE}, the value is loaded at
125+
* runtime regardless of whether it has a URL prefix; bare relative paths
126+
* are normalized to {@code context://<value>} and served as static
127+
* resources by the servlet container.
102128
*
103129
* @return a JavaScript file URL
104130
*/
105131
String value();
106132

133+
/**
134+
* The kind of {@code <script>} tag to use when loading the file. Defaults
135+
* to {@link Type#SCRIPT} (a classic {@code <script>} element). Set to
136+
* {@link Type#MODULE} to render a {@code <script type="module">} element
137+
* instead, e.g. for hand-authored or CDN-hosted ES modules that should not
138+
* go through Vite. For build-time bundled ES modules use {@link JsModule}
139+
* instead.
140+
*
141+
* @return the kind of script tag to render
142+
*/
143+
Type type() default Type.SCRIPT;
144+
107145
/**
108146
* Defines if the JavaScript should be loaded only when running in
109147
* development mode (for development tooling etc.) or if it should always be

flow-server/src/main/java/com/vaadin/flow/component/internal/UIInternals.java

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@
8989
import com.vaadin.flow.server.communication.PushConnection;
9090
import com.vaadin.flow.shared.Registration;
9191
import com.vaadin.flow.shared.communication.PushMode;
92+
import com.vaadin.flow.shared.ui.LoadMode;
9293
import com.vaadin.flow.signals.local.ValueSignal;
9394

9495
/**
@@ -1153,12 +1154,24 @@ private void maybeWarnAboutDependencies(
11531154

11541155
private void addExternalDependencies(DependencyInfo dependency) {
11551156
Page page = ui.getPage();
1156-
dependency.getJavaScripts().stream()
1157-
.filter(js -> UrlUtil.isExternal(js.value()))
1158-
.forEach(js -> page.addJavaScript(js.value(), js.loadMode()));
1157+
dependency.getJavaScripts().stream().filter(this::isRuntimeJavaScript)
1158+
.forEach(js -> {
1159+
String resolved = FrontendDependencyUrlResolver
1160+
.resolveToContextRoot(js.value());
1161+
if (resolved == null) {
1162+
return;
1163+
}
1164+
page.addJavaScript(resolved, js.loadMode(), js.type());
1165+
});
11591166
dependency.getJsModules().stream()
11601167
.filter(js -> UrlUtil.isExternal(js.value()))
1161-
.forEach(js -> page.addJsModule(js.value()));
1168+
.forEach(js -> page.addJavaScript(js.value(), LoadMode.EAGER,
1169+
JavaScript.Type.MODULE));
1170+
}
1171+
1172+
private boolean isRuntimeJavaScript(JavaScript js) {
1173+
return js.type() == JavaScript.Type.MODULE
1174+
|| UrlUtil.isExternal(js.value());
11621175
}
11631176

11641177
/**

flow-server/src/main/java/com/vaadin/flow/component/page/Page.java

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,42 @@ public void addJavaScript(String url) {
256256
* details
257257
*/
258258
public void addJavaScript(String url, LoadMode loadMode) {
259-
addDependency(new Dependency(Type.JAVASCRIPT, url, loadMode));
259+
addJavaScript(url, loadMode, JavaScript.Type.SCRIPT);
260+
}
261+
262+
/**
263+
* Adds the given JavaScript to the page and ensures that it is loaded
264+
* successfully.
265+
* <p>
266+
* Relative URLs are interpreted as relative to the static web resources
267+
* directory. You can prefix the URL with {@code context://} to make it
268+
* relative to the context path or use an absolute URL to refer to files
269+
* outside the frontend directory.
270+
* <p>
271+
* The {@code type} parameter selects the kind of {@code <script>} tag the
272+
* browser receives: {@link JavaScript.Type#SCRIPT} renders a classic
273+
* {@code <script>} element (the default of {@link #addJavaScript(String)});
274+
* {@link JavaScript.Type#MODULE} renders a {@code <script type="module">}
275+
* element, which is the recommended way to load runtime ES modules
276+
* (replaces the deprecated {@link #addJsModule(String)}).
277+
* <p>
278+
* For component related JavaScript dependencies, you should use the
279+
* {@link JavaScript @JavaScript} annotation.
280+
*
281+
* @param url
282+
* the URL to load the JavaScript from, not <code>null</code>
283+
* @param loadMode
284+
* determines dependency load mode, refer to {@link LoadMode} for
285+
* details
286+
* @param type
287+
* the kind of {@code <script>} tag to render; {@code null} is
288+
* treated as {@link JavaScript.Type#SCRIPT}
289+
*/
290+
public void addJavaScript(String url, LoadMode loadMode,
291+
JavaScript.Type type) {
292+
Type dependencyType = type == JavaScript.Type.MODULE ? Type.JS_MODULE
293+
: Type.JAVASCRIPT;
294+
addDependency(new Dependency(dependencyType, url, loadMode));
260295
}
261296

262297
/**
@@ -269,7 +304,11 @@ public void addJavaScript(String url, LoadMode loadMode) {
269304
* @param url
270305
* the URL to load the JavaScript module from, not
271306
* <code>null</code>
307+
* @deprecated use {@link #addJavaScript(String, LoadMode, JavaScript.Type)}
308+
* with {@link JavaScript.Type#MODULE} instead. The new overload
309+
* also accepts a {@link LoadMode}.
272310
*/
311+
@Deprecated
273312
public void addJsModule(String url) {
274313
if (UrlUtil.isExternal(url) || url.startsWith("/")) {
275314
addDependency(new Dependency(Type.JS_MODULE, url, LoadMode.EAGER));

0 commit comments

Comments
 (0)