Repository navigation
feat: serve @JavaScript with a runtime prefix as a runtime <script> - #24653
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.
@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.
|
|
Merge main. |
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>
Memory footprintNo change compared with the base commit a59da97. |
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>
|
Fixed the java:S1449 issue: URL prefix checks now lower-case with |
|
merge main |
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@Artur- Merged latest |
|
Runtime-prefixed 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
Before this PR, the build-time scanner ( Diagram Bot draws the mechanism this pull request touches; it does not review the change. Verify it against the diff.
|
vaadin-review-bot
left a comment
There was a problem hiding this comment.
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 |
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 tofrontenddirectory and they are bundled". Leading-slash andbase://values are no longer bundled. - Nothing in the annotation says the bundled interpretation is deprecated, and neither
@JavaScriptnorvalue()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( |
There was a problem hiding this comment.
📝 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
There was a problem hiding this comment.
@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.
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>
|
@vaadin-review-bot Addressed the two findings that weren't attached to the diff:
|
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Replaced the lambda with |
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>
|
It looks like there's an issue with hotswap: if I change the value for a |
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>
|
@mcollovati Good catch. A script that has run cannot be removed from the page, so a refresh can only add the new |
| } | ||
| // Logged once per scan rather than per usage, as annotations coming | ||
| // from add-ons cannot be changed by the application developer | ||
| log().warn("Found @JavaScript values that are bundled. " |
There was a problem hiding this comment.
@JavaScript on the AppShellConfigurator is only seen by the scanners. AppShellRegistry reads @StyleSheet, @Meta, @Inline and others from the app shell, but not @JavaScript, and UIInternals.addRuntimeDependencies only runs for components. So once the scanners drop a runtime value from the bundle, nothing adds it at runtime.
For http:// and context:// values on the app shell this was already the case before this PR. With this PR, two things make it worse:
@JavaScript("/x.js")on the app shell joins that group, so it is no longer loaded at all.- This warning lists app-shell usages too (
- org.example.AppShell: "./x.js") and tells the developer to move the file to a static location and usecontext://. On the app shell that leaves the script never loaded, with no error.
I checked this with an app at 894880dc5c and on 25.3.4, in development mode, production mode and hotdeploy. The app shell's "./x.js" and @JsModule values load on both versions. Its context:// and http:// values never load on either version. With this PR, its / and Type.MODULE values never load either. Nothing is logged in any of these cases.
Possible fixes:
- Make
AppShellRegistryadd runtime@JavaScriptvalues to the page, the same way it handles@StyleSheet(resolved throughFrontendDependencyUrlResolver, and skippingdevelopmentOnlyin production). - Or, as a minimum, give app-shell usages a different hint (
@JsModule, or a component), and warn at build time when a runtime value is found on the app shell.
@JavaScript placed directly on a WebComponentExporter probably has the same gap, but I have not checked that.
There was a problem hiding this comment.
@mcollovati Thanks, fixed in 25842f5 with the first option. AppShellRegistry now adds the app shell's runtime @JavaScript values (runtime prefix, other schemes, Type.MODULE) to the index page as <script> elements. They are resolved through FrontendDependencyUrlResolver the same way as on components; UIInternals and the registry now share the new resolveRuntimeJavaScript. Type.MODULE gives type="module", LoadMode.LAZY gives defer, and developmentOnly values are skipped in production. LoadMode.INLINE throws on the app shell and points to @Inline. With this in place, the warning's context:// advice also works for app-shell usages, so I left its wording as is. Tests in VaadinAppShellInitializerTest cover the order, attributes, de-duplication, production filtering and the INLINE error. A follow-up commit, 208cfdb, also makes the hotswapper reload all UIs when a runtime @JavaScript on the app shell changes, because those scripts are rendered into the initial page. I haven't looked at @JavaScript on a WebComponentExporter yet.
| // hotswappers have run, so it still holds the values from before the | ||
| // redefinition | ||
| for (Class<? extends Component> type : componentClasses(event)) { | ||
| previousScripts.put(type.getName(), |
There was a problem hiding this comment.
The "before" value is read through ComponentUtil.getDependencies(service, type). That only gives the old values when the changed class itself has a cached ComponentMetaData. Two common cases don't have one:
- A parent class.
abstract class Basehas@JavaScript("context://a.js"), and the view isView extends Base. Change or remove the annotation onBaseand hotswap. OnlyBaseis in the changed classes. It was never instantiated, so it has no cache entry, andgetDependenciescomputes "before" from the class that is already redefined. Before and after are then equal, so there is no reload. - A
@Usestarget.Viewhas@Uses(Helper.class), andHelperhas a runtime@JavaScript. Change it onHelper.Helperusually has no cache entry either, andisComponentInUse(ui, Helper.class)is false when noHelperinstance is in the tree. So there is no reload.
I checked both cases with the dev loop (apply --no-restart), on 25.3.4 and with this PR. In both, the new value never loads until a restart, not even in a newly loaded page, because the view's cached metadata still holds the old value. That stale cache is older than this PR. What this PR adds is a hotswapper that handles the view's own class (I checked it: the page is reloaded and only the new script runs), but not a parent class or a @Uses target, where it sees no change at all.
The tests load a new copy of the class under the same name rather than redefining it in place, so they don't run into this.
A possible fix: on a change, also clear the cached metadata of the classes that extend or @Uses a changed class, and compare their before and after values. That also fixes the stale script in a new page.
There was a problem hiding this comment.
@mcollovati Fixed in 5d4a063 and 208cfdb. The session handler now walks the components in use in each UI. For each one whose class, a superclass, or a (transitive) @Uses target is a changed class, it compares before and after. For a component whose own class didn't change, "before" comes from its cached metadata, which still holds the old values. That cache entry is then cleared, so a newly loaded page gets the new value too. Every component class in use is checked, not just up to the first one that needs a reload. The new tests redefine the class in place through the ByteBuddy agent, not as a copy under the same name, so they cover the parent-class and @Uses-target cases you describe. They also check that the component's dependencies return the new value afterwards. One case is still open: if no UI has the dependent component open during the hotswap, its stale cache entry stays until restart. That's the older cache behaviour, and there is no API to find the dependent classes in the cache without a UI.
| Set<RuntimeScript> previous = previousScripts.get(type.getName()); | ||
| // Read the same way as the previous values, so that @Uses | ||
| // dependencies are on both sides | ||
| if (previous == null || runtimeScripts(new ComponentMetaData(type) |
There was a problem hiding this comment.
Only runtime values are compared here, so a hotswap that moves a value between bundled and runtime is not handled. The bundle (or the generated imports, with hotdeploy) only changes at startup:
@JavaScript("./x.js")→@JavaScript("context://x.js"): the old set is empty, socontainsAllis true and there is no reload. The route refresh adds<script src="context://x.js">, and the bundle still runsx.js, so the file runs twice.@JavaScript("context://x.js")→@JavaScript("./x.js"): this triggers a reload, butx.jsis not in the bundle that was built at startup, so the file is not loaded at all.
I checked both cases with the dev loop (apply --no-restart), on 25.3.4 and with this PR. The first case runs the file twice, both on the page that stays open and on a freshly loaded page. The second case loads nothing. Neither is new: 25.3.4 behaves the same for context:// and http:// values. But with this PR more values are loaded at runtime, and the new JavaScriptHotswapper is the natural place to catch both cases. In the first case it does not reload the page today, so nothing tells the developer that a restart is needed.
Since this cannot work without a restart, could the hotswapper detect a value that moved between bundled and runtime (or any change to a bundled @JavaScript), and log that a restart is needed? JsDefinitionHotswapper.warnAboutMissingDefinitions does the same for JavaScript definitions. Then the developer at least knows why the script runs twice or not at all.
There was a problem hiding this comment.
@mcollovati Done in 5d4a063 and 208cfdb. The hotswapper now records all @JavaScript values, not only runtime ones. When the bundled set of a changed class (whether or not it is in use) or of a component in use changes, it logs a warning that a restart is needed (once per class per hotswap). That covers a changed bundled value and a value moved between bundled and runtime in either direction. In the ./x.js → context://x.js case nothing is reloaded, but the warning explains why the script runs twice. The reverse case still reloads for the removed runtime value and also logs the warning. Tests cover a changed bundled value and both directions of the move.
|
Dev loop restarts the app for runtime With this PR, a runtime The dev loop still restarts the app for every I checked this with an app and the dev loop:
So for runtime values the restart only costs time. I suggest a follow-up issue after this PR is merged, so that it can use
|
The scanner keeps runtime @javascript values out of the bundle, but the app shell is not a component, so UIInternals never adds them to the page and they were not loaded at all. AppShellRegistry now adds them to the index page as script elements, skipping developmentOnly values in production. LoadMode.INLINE is rejected there, as @inline covers that case. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pper A change to a runtime @javascript on a superclass or a @uses target of a component in use was not noticed: the changed class usually has no cached metadata, so the previous values could not be read, while the component in use kept its cached metadata with the old values, also for new pages. The hotswapper now walks the components in use, reads the previous values of those that depend on a changed class from their cached metadata, and clears that cache. Bundled values only change at startup, so a change to a bundled value, or a value moved between bundled and runtime, now logs that a restart is needed instead of silently running the script twice or not at all. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Check every component class in use instead of stopping at the first one that needs a reload, so the stale metadata of each is cleared. Warn about changed bundled values of a changed class also when it is not in use, and reload all UIs when a runtime @javascript value on the app shell changes, as those are rendered into the initial page. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@mcollovati Agreed on the dev loop suggestion. Leaving out the values that |
With the coverage profile, JaCoCo adds a method to the test classes when they are loaded, so redefining them from their class files fails with "attempted to delete a method". Redefine copies defined from the class files instead; they have no code source, so JaCoCo leaves them alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Move adding a single app shell script into its own method, make the operator precedence explicit, and tidy two tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Fixed all four Sonar findings in the latest commit. |
|
Describe that a @javascript value with a runtime prefix (context://, base://, a leading /, http(s)://, //, or another scheme) is loaded as a runtime script instead of being bundled, and that bundling a bare relative @javascript value is deprecated in favor of @jsmodule. Updates articles/flow/advanced/loading-resources.adoc. Documents vaadin/flow#24653 (`7e4f4942cd59453d143191947639dc56a7d936f0`).
|
Pull request created: #6463
|
|
Documentation Bot: Draft documentation pull request for this change: vaadin/docs#6463 Files updated:
It was written from the state of this pull request as you see it now. Please review it and mark it ready for review.
|





@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.
FrontendClassVisitorfor production andFullDependenciesScannerfor dev mode) filter runtime-prefixed@JavaScriptvalues out of the bundle imports.@JavaScriptvalues are still bundled for backwards compatibility, butFrontendDependencieslogs a single build-time warning that lists every such usage. The warning recommends@JsModulefor bundling, or moving the file to a static location and using a runtime prefix. The@JavaScriptjavadoc documents this interpretation as deprecated.Behavior change: before this change,
@JavaScript("/foo.js")pointing atfrontend/foo.jswas 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 = trueis now honoured for dependencies that are loaded at runtime instead of bundled. Before, a runtime@JavaScriptvalue (runtime prefix orType.MODULE) or an external@JsModuleURL such as@JsModule(value = "https://…/dev-helper.js", developmentOnly = true)was also loaded in production. The@JsModulecase and the prefixed@JavaScriptcases other than a leading/were already affected before this PR.Hotswap: a runtime
@JavaScriptvalue 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 newJavaScriptHotswapperreloads 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. The hotswapper also handles a change on a superclass or a@Usestarget of a component in use: it reads the previous values from the cached metadata of the component in use and clears that cache, so new pages get the new value too. This only applies to components shown in an open UI during the hotswap; others keep their cached metadata until a restart, as before. A runtime@JavaScriptchange on the app shell reloads all UIs, since those scripts are rendered into the initial page. A change to a bundled@JavaScriptvalue, or a value moved between bundled and runtime, cannot be applied without a restart (the bundle is built at startup), so the hotswapper logs a warning saying so, for a changed class whether or not it is in use. To share the runtime check,FrontendDependencyUrlResolver.isRuntimeJavaScript(JavaScript)is added andComponentMetaData.DependencyInfo.getJavaScripts()is made public (both internal-use classes).App shell: runtime
@JavaScriptvalues on theAppShellConfiguratorwere kept out of the bundle by the scanners but never added to the page, since the app shell is not a component (forcontext://andhttp(s)://values this was already the case before this PR).AppShellRegistrynow adds them to the index page as<script>elements (type="module"forType.MODULE,deferforLoadMode.LAZY), resolved the same way as on components, and skipsdevelopmentOnlyvalues in production.LoadMode.INLINEis rejected on the app shell with a hint to use@Inline.API Changes