Skip to content

feat: serve @JavaScript with a runtime prefix as a runtime <script> - #24653

Open
Artur- wants to merge 26 commits into
mainfrom
javascript-is-runtime
Open

Artur- wants to merge 26 commits into
mainfrom
javascript-is-runtime

Conversation

@Artur-

@Artur- Artur- commented Jun 16, 2026 •

Copy link
Copy Markdown
Member

@javascript values with a runtime prefix (context://, base://, /abs, http(s)://, //) are now served as runtime <script> elements rather than bundled, matching how the corresponding @Stylesheet values are resolved.

  • New FrontendDependencyUrlResolver.isRuntimeDependencyUrl recognises the runtime prefix table (http(s)://, //, context://, base://, /abs) and is the single source of truth for distinguishing runtime URLs from bundler import specifiers.
  • UIInternals treats such @javascript values as runtime dependencies via isRuntimeJavaScript, alongside type=MODULE values.
  • Both dependency scanners (FrontendClassVisitor for production and FullDependenciesScanner for dev mode) filter runtime-prefixed @JavaScript values out of the bundle imports.
  • Bare-relative @JavaScript values are still bundled for backwards compatibility, but FrontendDependencies logs a single build-time warning that lists every such usage. The warning recommends @JsModule for bundling, or moving the file to a static location and using a runtime prefix. The @JavaScript javadoc documents this interpretation as deprecated.

Behavior change: before this change, @JavaScript("/foo.js") pointing at frontend/foo.js was bundled, because the bundler resolves a leading slash against the frontend folder. It now renders <script src="/foo.js"> resolved against the context root, which returns 404 unless the file is also served as a static resource. No build-time warning covers this form, since a leading slash is now a valid runtime URL and the scanner cannot tell the two uses apart. To keep bundling such a file, switch to @JsModule("./foo.js"). This needs a release note.

Production-mode fix: developmentOnly = true is now honoured for dependencies that are loaded at runtime instead of bundled. Before, a runtime @JavaScript value (runtime prefix or Type.MODULE) or an external @JsModule URL such as @JsModule(value = "https://…/dev-helper.js", developmentOnly = true) was also loaded in production. The @JsModule case and the prefixed @JavaScript cases other than a leading / were already affected before this PR.

Hotswap: a runtime @JavaScript value is added as a <script> element, which cannot be removed from the page. When such a value is changed or removed on a component in use, the new JavaScriptHotswapper reloads the affected UIs instead of refreshing them, so the old script is not left running next to the new one. Adding a value still only refreshes. To share the runtime check, FrontendDependencyUrlResolver.isRuntimeJavaScript(JavaScript) is added and ComponentMetaData.DependencyInfo.getJavaScripts() is made public (both internal-use classes).

Artur- added 2 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.
@javascript values with a runtime prefix (context://, base://, /abs,
http(s)://, //) are now served as runtime <script> elements rather than
bundled, matching how the corresponding @Stylesheet values are resolved.

- New FrontendDependencyUrlResolver.isRuntimeDependencyUrl recognises the
  runtime prefix table (http(s)://, //, context://, base://, /abs) and is
  the single source of truth for distinguishing runtime URLs from bundler
  import specifiers.
- UIInternals treats such @javascript values as runtime dependencies via
  isRuntimeJavaScript, alongside type=MODULE values.
- The scanner (FrontendClassVisitor) filters runtime-prefixed @javascript
  values out of the bundle imports.
- Bare-relative @javascript values still bundle for backwards
  compatibility but FrontendDependencies emits a one-time consolidated
  build-time warning per (class, value) recommending migration.
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
0.0% Coverage on New Code (required ≥ 80%)

See analysis details on SonarQube Cloud

@github-actions

github-actions Bot commented Jun 16, 2026 •

Copy link
Copy Markdown
Contributor

Test Results

 1 668 files  + 1   1 754 suites  +1   2h 12m 25s ⏱️ + 4m 10s
12 968 tests +28  12 899 ✅ +28  69 💤 ±0  0 ❌ ±0 
13 602 runs  +28  13 533 ✅ +28  69 💤 ±0  0 ❌ ±0 

Results for commit 894880d. ± Comparison against base commit a59da97.

♻️ This comment has been updated with latest results.

@Artur-

Artur- commented Oct 15, 2026

Copy link
Copy Markdown
Member Author

Merge main.

totally-not-ai Bot and others added 2 commits October 15, 2026 06:32
Resolve conflicts with the squash-merged #24239 (type attribute for
@javascript) by taking main's version and re-applying this branch's
runtime-prefix change on top.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The merge commit picked up copyright header rewrites from the
formatter hook in files unrelated to this change. Restore them to
main's version and update the resolveRuntimeJavaScript Javadoc to
match the values that now reach it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Memory footprint

No change compared with the base commit a59da97.

@github-actions github-actions Bot added +0.0.1 and removed +0.1.0 labels Oct 15, 2026
totally-not-ai Bot and others added 2 commits October 15, 2026 06:56
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Removes the duplicated prefix check, which also lower-cased without
Locale.ROOT.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@totally-not-ai

Copy link
Copy Markdown
Contributor

Fixed the java:S1449 issue: URL prefix checks now lower-case with Locale.ROOT, so they don't depend on the default locale (e.g. the Turkish dotless i). resolveToContextRoot now reuses isRuntimeDependencyUrl instead of its own copy of the same prefix check, which had the same locale problem.

@Artur-

Artur- commented Oct 15, 2026

Copy link
Copy Markdown
Member Author

merge main

@totally-not-ai

Copy link
Copy Markdown
Contributor

@Artur- Merged latest main into the branch (no conflicts); flow-server compiles and the resolver tests pass.

@Artur-
Artur- marked this pull request as ready for review October 16, 2026 08:14
@github-actions

Copy link
Copy Markdown
Contributor

Runtime-prefixed @JavaScript values now take the same path out of the bundle as @StyleSheet values

flowchart LR
    subgraph Before
        direction TB
        B1["JavaScript annotation ((foo.js/redacted) -->|"build-time scan"| B2["FrontendClassVisitor.JSAnnotationVisitor: not type=MODULE, so bundle it"]
        B1 -->|"render time"| B3["UIInternals.isRuntimeJavaScript(): not MODULE, not UrlUtil.isExternal() - not runtime"]
        B2 --> B4["classInfo.scripts (bundle import)"]
        B3 --> B5["no runtime script tag emitted"]
    end
    subgraph After
        direction TB
        A1["JavaScript annotation ((foo.js/redacted) -->|"build-time scan"| A2["FrontendDependencyUrlResolver.isRuntimeDependencyUrl() true - skip bundle"]:::changed
        A1 -->|"render time"| A3["FrontendDependencyUrlResolver.isRuntimeDependencyUrl() true - is runtime"]:::changed
        A2 --> A4["excluded from classInfo.scripts"]
        A3 --> A5["UIInternals.addRuntimeDependencies() emits script tag"]:::changed
    end
    Before ~~~ After
    classDef changed stroke:#c9a227,stroke-width:3px
Loading

Before this PR, the build-time scanner (FrontendClassVisitor.JSAnnotationVisitor) only excluded type=MODULE values from the bundle, so a runtime-prefixed value like (foo.js/redacted) still went into classInfo.scripts, while UIInternals.isRuntimeJavaScript()only recognizedtype=MODULEorUrlUtil.isExternal()(plainhttp(s)://///) as runtime, so no (script)tag was ever emitted for it. The newFrontendDependencyUrlResolver.isRuntimeDependencyUrl()` predicate is now the single check both sides use, so the same value is consistently excluded from the bundle and served as a runtime script.

Diagram Bot draws the mechanism this pull request touches; it does not review the change. Verify it against the diff.

Generated by Diagram Bot for #24653 · agent · 82.8 AIC · ⊞ 9.2K · ◷

@vaadin-review-bot vaadin-review-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed the changes — left 1 comment and collected 2 findings below that could not be attached to the diff.

Finding
⚠️ Only the bytecode scanner excludes runtime-prefixed @javascript values; the full scanner, which is the default in dev mode, still bundles them
📝 The @javascript javadoc still documents bundled bare-relative values as normal behavior while the build now calls that usage deprecated
📝 The deprecation warning's first two suggestions do not work for files in the frontend directory

⚠️ Only the bytecode scanner excludes runtime-prefixed @javascript values; the full scanner, which is the default in dev mode, still bundles them
FullDependenciesScanner.collectScripts got the Type.MODULE exclusion but not the new runtime-prefix exclusion. It is the scanner selected whenever optimizeBundle is false, which is the default for dev mode (DevModeInitializer) and for BuildFrontendUtil's dev-bundle path.

context:// and base:// survive this because AbstractUpdateImports.resolveModules filters those prefixes later, but a leading-slash value does not: @JavaScript("/absolute.js") still ends up as import '/absolute.js'; in the generated imports while also being added as a runtime <script>. So the feature behaves one way in a production build and another way in dev.

The module-exclusion equivalent has a test in both scanners (FullDependenciesScannerTest.getScripts_javaScriptWithTypeModule_excludedFromBundleImports); the runtime-prefix exclusion only has one in FrontendDependenciesTest.
FullDependenciesScanner.java:395 · correctness

📝 The @javascript javadoc still documents bundled bare-relative values as normal behavior while the build now calls that usage deprecated
Two mismatches introduced by this change:

  • The class javadoc says "Other paths used in the value() method are considered as relative to frontend directory and they are bundled". Leading-slash and base:// values are no longer bundled.
  • Nothing in the annotation says the bundled interpretation is deprecated, and neither @JavaScript nor value() carries @Deprecated, yet every build now prints "uses the deprecated bundled interpretation" for the usage the javadoc presents as the normal one.
    JavaScript.java:55 · documentation

}
for (String value : classInfo.scripts) {
if (warnedJavaScriptKeys.add(className + ':' + value)) {
log().warn(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

📝 The deprecation warning's first two suggestions do not work for files in the frontend directory

Bare-relative @JavaScript values are documented as relative to the frontend directory. Both context:// and Type.MODULE resolve against the servlet context root, where such a file is not served, so following either suggestion gives a 404 unless the file is also moved out of frontend/. Only the @JsModule suggestion works as written.

The warning also fires for classes from third-party add-ons, which the application developer cannot change and cannot silence.

FrontendDependencies.java:247 · documentation

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@vaadin-review-bot Good catch. I reworded the warning. Its first suggestion now says to replace @JavaScript with @JsModule using the same value, which works as-is for files in frontend/. For loading at runtime, it now says the file must first be moved out of frontend/ to a statically served location (e.g. src/main/resources/META-INF/resources), and only then be referenced with context:// or type = Type.MODULE. Since add-on annotations can't be changed by the app developer, the warning is now logged once per scan with all usages listed, instead of once per annotation. It also notes that usages in add-ons need to be updated by the add-on maintainer.

totally-not-ai Bot and others added 2 commits October 16, 2026 08:34
The full dependencies scanner, used in dev mode, still bundled
@javascript values with a leading slash. Exclude runtime-prefixed
values there as well so dev and production builds agree.

Log the deprecation warning once per scan listing all usages, with
suggestions that work for files located in the frontend directory,
and document the deprecated bundled interpretation in the javadoc.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Verify that all bundled @javascript usages end up in a single warning,
that no warning is logged when only runtime values are used, and that
@jsmodule values with a runtime prefix are still bundled by the full
scanner. Make the warning wording apply to npm package values too and
stop promising a build warning in the javadoc, as dev mode does not
emit it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@totally-not-ai

Copy link
Copy Markdown
Contributor

@vaadin-review-bot Addressed the two findings that weren't attached to the diff:

  • Full scanner: FullDependenciesScanner.collectScripts now skips runtime-prefixed @JavaScript values in the same way as the bytecode scanner. This means @JavaScript("/absolute.js") is no longer emitted as an import in dev mode. The FullDependenciesScannerTest sample component now includes context://, base:// and leading-slash values, and the test checks that only the bare relative value is bundled.
  • Javadoc: the class and value() javadoc now list the runtime prefixes, including leading / and base://, and state that these are never bundled. They also say that the bundled interpretation of bare relative values is deprecated and point to @JsModule instead. I didn't add @Deprecated to value() because only one interpretation of its values is deprecated, not the attribute itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@totally-not-ai

Copy link
Copy Markdown
Contributor

Replaced the lambda with JavaScript::value in UIInternals (S1612). I left the S1066 finding at UIInternals.java:2231 (setChildModeless) as it is: that nested if is existing code this PR doesn't touch, so changing it here would add unrelated changes to the diff.

Artur- and others added 3 commits October 19, 2026 09:46
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add http(s):// and protocol-relative values to the scanner samples so
the tests check they are kept out of the bundle, and explain why
runtime developmentOnly scripts are filtered in production mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
totally-not-ai Bot and others added 2 commits October 20, 2026 10:08
External @jsmodule URLs are added to the page at runtime and never go
through the bundle, so developmentOnly = true was ignored for them and
the module was loaded in production too. Apply the same check as for
runtime @javascript values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
totally-not-ai Bot and others added 5 commits October 20, 2026 13:22
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The build drops every value with a scheme (UrlUtil.isExternal) from the
bundle, so a @javascript value such as ftp://... that has none of the
runtime prefixes was neither bundled nor loaded. Treat such values as
runtime values again, as before, both in the scanners and in UIInternals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Also check that such values are not reported as missing from the
production bundle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The fixtures used a frontend:// pseudo-scheme, which the bundler drops
like any other URL with a scheme and which is now treated as a runtime
value by the scanner as well. Use plain relative values so the tests
keep exercising bundled script collection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mcollovati

Copy link
Copy Markdown
Collaborator

It looks like there's an issue with hotswap: if I change the value for a @Javascript annotation that now is served as <script>, the browser will load the new JavaScript and also keep the old one.
IIRC, a loaded JavaScript cannot be removed from the page, but I wonder if the hotswapper should reload the page instead of doing a refresh in this case.

totally-not-ai Bot and others added 2 commits October 20, 2026 17:08
A @javascript value that is loaded at runtime is added as a script
element, which cannot be removed from the page again. Refreshing the UI
after the value was changed or removed kept the old script and added the
new one next to it. Reload the UIs using the component instead.

The check for runtime @javascript values moves to
FrontendDependencyUrlResolver so that UIInternals and the hotswapper
share it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Read the scripts after the redefinition through the component metadata
as well, so scripts of @uses targets do not look removed and trigger a
reload on every redefinition. Add tests for closing UIs, Type.MODULE
values, clearing the recorded state and isRuntimeJavaScript.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@totally-not-ai

Copy link
Copy Markdown
Contributor

@mcollovati Good catch. A script that has run cannot be removed from the page, so a refresh can only add the new <script> next to the old one. I added a JavaScriptHotswapper. Before the metadata cache is cleared, it records the runtime @JavaScript values (value and type) of each redefined component. If any of them was removed or changed, it requests UIUpdateStrategy.RELOAD for every UI where the component is in use. Only adding a value keeps the refresh, because the new script is simply added. Bundled @JavaScript values are not affected. To share the runtime check with UIInternals, it moved to FrontendDependencyUrlResolver.isRuntimeJavaScript. JavaScriptHotswapperTest covers changing the value, changing the type, adding a value, a bundled value, and a component that is not in use.

@sonarqubecloud

Copy link
Copy Markdown

This branch has not been deployed

No deployments
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.

3 participants