diff --git a/flow-server/src/main/resources/vite.generated.ts b/flow-server/src/main/resources/vite.generated.ts
index ad4a30d4adf..d51a2f8257c 100644
--- a/flow-server/src/main/resources/vite.generated.ts
+++ b/flow-server/src/main/resources/vite.generated.ts
@@ -24,6 +24,7 @@ import {
} from 'vite';
import brotli from 'rollup-plugin-brotli';
+import MagicString from 'magic-string';
import checker from 'vite-plugin-checker';
import postcssLit from '#buildFolder#/plugins/rollup-plugin-postcss-lit-custom/rollup-plugin-postcss-lit.js';
import vaadinI18n from '#buildFolder#/plugins/rollup-plugin-vaadin-i18n/rollup-plugin-vaadin-i18n.js';
@@ -465,24 +466,31 @@ function preserveUsageStats() {
return {
name: 'vaadin:preserve-usage-stats',
+ // A hook that returns code must return a sourcemap for it as well, or
+ // return null to leave the module as it is. Code without a map makes the
+ // bundler drop the module from the sourcemap of the chunk it ends up in,
+ // and this hook sees every module of the bundle.
transform(src: string, id: string) {
- if (id.includes('vaadin-usage-statistics')) {
- if (src.includes('vaadin-dev-mode:start')) {
- const expectedComment = '/*! vaadin-dev-mode:start';
- const newSrc = src.replace(DEV_MODE_START_REGEXP, expectedComment);
- if (newSrc === src) {
- if (!src.includes(expectedComment)) {
- console.error('vaadin-dev-mode:start tag not found');
- }
- } else if (!newSrc.match(DEV_MODE_CODE_REGEXP)) {
- console.error('New comment fails to match original regexp');
- } else {
- return { code: newSrc };
- }
+ if (!id.includes('vaadin-usage-statistics') || !src.includes('vaadin-dev-mode:start')) {
+ return null;
+ }
+
+ const expectedComment = '/*! vaadin-dev-mode:start';
+ const magicString = new MagicString(src).replace(DEV_MODE_START_REGEXP, expectedComment);
+ if (!magicString.hasChanged()) {
+ if (!src.includes(expectedComment)) {
+ console.error('vaadin-dev-mode:start tag not found');
}
+ return null;
+ }
+
+ const code = magicString.toString();
+ if (!code.match(DEV_MODE_CODE_REGEXP)) {
+ console.error('New comment fails to match original regexp');
+ return null;
}
- return { code: src };
+ return { code, map: magicString.generateMap({ hires: true }) };
}
};
}
diff --git a/flow-test-util/src/main/java/com/vaadin/flow/testutil/SourceMapTestUtil.java b/flow-test-util/src/main/java/com/vaadin/flow/testutil/SourceMapTestUtil.java
new file mode 100644
index 00000000000..d1ce191d368
--- /dev/null
+++ b/flow-test-util/src/main/java/com/vaadin/flow/testutil/SourceMapTestUtil.java
@@ -0,0 +1,78 @@
+/*
+ * Copyright 2000-2026 Vaadin Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package com.vaadin.flow.testutil;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.Assert;
+import tools.jackson.databind.JsonNode;
+import tools.jackson.databind.json.JsonMapper;
+
+/**
+ * Helpers for the tests that check the sourcemaps a frontend build emits.
+ *
+ * A build plugin that rewrites a module or a chunk has to chain its sourcemap
+ * onto the one the bundler already has, otherwise the emitted map ends up
+ * without the original sources and the browser cannot map the bundle back to
+ * them.
+ */
+public final class SourceMapTestUtil {
+
+ private SourceMapTestUtil() {
+ }
+
+ /**
+ * Asserts that the given sourcemap can be used to map the bundle it belongs
+ * to back to the files it was built from.
+ *
+ * @param name
+ * the name of the sourcemap, used in the assertion messages
+ * @param sourceMap
+ * the contents of the sourcemap file
+ * @return the original files the sourcemap refers to
+ */
+ public static List assertSourceMapUsable(String name,
+ String sourceMap) {
+ JsonNode contents = JsonMapper.shared().readTree(sourceMap);
+ JsonNode sources = contents.get("sources");
+ JsonNode sourcesContent = contents.get("sourcesContent");
+
+ Assert.assertNotEquals(name + " should have a sourcemap with sources",
+ 0, sources.size());
+ Assert.assertNotEquals(name + " should have a sourcemap with mappings",
+ "", contents.get("mappings").asString());
+ Assert.assertEquals(
+ name + " should have the contents of every source in its "
+ + "sourcemap",
+ sources.size(), sourcesContent.size());
+
+ List sourceNames = new ArrayList<>();
+ for (int i = 0; i < sources.size(); i++) {
+ String source = sources.get(i).asString();
+ Assert.assertNotEquals(
+ name + " should have a sourcemap referring to the "
+ + "original files, was " + source,
+ "", source.trim());
+ Assert.assertNotEquals(
+ name + " should have the contents of " + source
+ + " in its sourcemap",
+ "", sourcesContent.get(i).asString().trim());
+ sourceNames.add(source.replace('\\', '/'));
+ }
+ return sourceNames;
+ }
+}
diff --git a/flow-tests/test-express-build/test-dev-bundle-frontend-add-on/src/main/frontend/vaadin-usage-statistics-stub.js b/flow-tests/test-express-build/test-dev-bundle-frontend-add-on/src/main/frontend/vaadin-usage-statistics-stub.js
new file mode 100644
index 00000000000..708c21b08fd
--- /dev/null
+++ b/flow-tests/test-express-build/test-dev-bundle-frontend-add-on/src/main/frontend/vaadin-usage-statistics-stub.js
@@ -0,0 +1,13 @@
+// Stands in for @vaadin/vaadin-usage-statistics, which a Flow only
+// application does not pull in. The published package has the comment below
+// in the rewritten form already, this file has the plain form that the
+// vaadin:preserve-usage-stats build plugin turns into /*! so that a minifier
+// keeps it. See DevBundleSourceMapsIT.
+function maybeGatherAndSendStats() {
+ /** vaadin-dev-mode:start
+ console.log('vaadin-usage-statistics-stub');
+ vaadin-dev-mode:end **/
+}
+
+// Keeps the module, and with it the comment, out of reach of tree shaking
+window.vaadinUsageStatisticsStub = maybeGatherAndSendStats;
diff --git a/flow-tests/test-express-build/test-dev-bundle-frontend-add-on/src/main/java/com/vaadin/flow/frontend/DevBundleJsModuleView.java b/flow-tests/test-express-build/test-dev-bundle-frontend-add-on/src/main/java/com/vaadin/flow/frontend/DevBundleJsModuleView.java
index 5bf17a0d96c..3a70cc77f32 100644
--- a/flow-tests/test-express-build/test-dev-bundle-frontend-add-on/src/main/java/com/vaadin/flow/frontend/DevBundleJsModuleView.java
+++ b/flow-tests/test-express-build/test-dev-bundle-frontend-add-on/src/main/java/com/vaadin/flow/frontend/DevBundleJsModuleView.java
@@ -23,6 +23,9 @@
@Route("com.vaadin.flow.frontend.DevBundleJsModuleView")
@JsModule("./test.ts")
@JsModule("./js/test.js")
+// Pulls a module named like @vaadin/vaadin-usage-statistics into the bundle,
+// so that the build plugin that rewrites its dev mode comment runs
+@JsModule("./vaadin-usage-statistics-stub.js")
public class DevBundleJsModuleView extends Div {
static final String SPAN_ID = "test-js-module-frontend-span";
diff --git a/flow-tests/test-express-build/test-dev-bundle-frontend-add-on/src/test/java/com/vaadin/flow/frontend/DevBundleSourceMapsIT.java b/flow-tests/test-express-build/test-dev-bundle-frontend-add-on/src/test/java/com/vaadin/flow/frontend/DevBundleSourceMapsIT.java
new file mode 100644
index 00000000000..622cb51858d
--- /dev/null
+++ b/flow-tests/test-express-build/test-dev-bundle-frontend-add-on/src/test/java/com/vaadin/flow/frontend/DevBundleSourceMapsIT.java
@@ -0,0 +1,143 @@
+/*
+ * Copyright 2000-2026 Vaadin Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package com.vaadin.flow.frontend;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.apache.commons.io.FileUtils;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+
+import com.vaadin.flow.server.Constants;
+import com.vaadin.flow.testutil.ChromeBrowserTest;
+import com.vaadin.flow.testutil.SourceMapTestUtil;
+
+/**
+ * Verifies that the dev bundle can be mapped back to the original sources. This
+ * module builds it with {@code build.sourcemap} enabled, see
+ * {@code vite.config.ts}.
+ */
+public class DevBundleSourceMapsIT extends ChromeBrowserTest {
+
+ private static final String LIT_VIEW_SOURCE = "src/main/frontend/views/lit-view.ts";
+ private static final String USAGE_STATISTICS_SOURCE = "src/main/frontend/vaadin-usage-statistics-stub.js";
+
+ /**
+ * A dev mode comment in a built chunk, with the form of its opening marker
+ * and its contents as the groups.
+ */
+ private static final Pattern DEV_MODE_COMMENT = Pattern.compile(
+ "/\\*([*!])\\s+vaadin-dev-mode:start([\\s\\S]*?)vaadin-dev-mode:end\\s+\\*\\*/");
+
+ @Before
+ public void init() {
+ // Opening a view makes the application build the dev bundle
+ open();
+ $("lit-view").waitForFirst();
+ }
+
+ @Override
+ protected String getTestPath() {
+ return "/view/com.vaadin.flow.frontend.LitView";
+ }
+
+ /**
+ * A build plugin that returns the code of a module without a sourcemap
+ * makes the bundler leave that module out of the sourcemap of the chunk it
+ * ends up in. A plugin that sees every module then leaves the emitted .map
+ * files without any of the original sources.
+ */
+ @Test
+ public void devBundleSourceMapsPointToOriginalSources() throws IOException {
+ List sourceMaps = getBundleFiles(".js.map");
+ Assert.assertFalse(
+ "The dev bundle should have been built with sourcemaps "
+ + "enabled, see vite.config.ts",
+ sourceMaps.isEmpty());
+
+ List sources = new ArrayList<>();
+ for (File sourceMap : sourceMaps) {
+ sources.addAll(SourceMapTestUtil.assertSourceMapUsable(
+ sourceMap.getName(), read(sourceMap)));
+ }
+
+ assertHasSource(sources, LIT_VIEW_SOURCE);
+ assertHasSource(sources, USAGE_STATISTICS_SOURCE);
+ }
+
+ /**
+ * Rewriting the dev mode comment of the usage statistics module so that it
+ * starts with {@code /*!}, which tells a minifier to keep it in the bundle,
+ * is the one thing the plugin does to the code.
+ */
+ @Test
+ public void usageStatisticsCommentIsRewrittenInTheBundle()
+ throws IOException {
+ int rewritten = 0;
+ for (File chunk : getBundleFiles(".js")) {
+ Matcher matcher = DEV_MODE_COMMENT.matcher(read(chunk));
+ while (matcher.find()) {
+ if (!matcher.group(2)
+ .contains("vaadin-usage-statistics-stub")) {
+ // A dev mode comment of another module, left as it is
+ continue;
+ }
+ Assert.assertEquals(chunk.getName()
+ + " should have the usage statistics comment rewritten "
+ + "into the form a minifier keeps", "!",
+ matcher.group(1));
+ rewritten++;
+ }
+ }
+ Assert.assertNotEquals(
+ "No chunk of the dev bundle has the usage statistics comment",
+ 0, rewritten);
+ }
+
+ private void assertHasSource(List sources, String source) {
+ Assert.assertTrue(
+ "A sourcemap of the dev bundle should refer to " + source
+ + ", only found " + sources,
+ sources.stream().anyMatch(name -> name.endsWith(source)));
+ }
+
+ /**
+ * Returns the files of the built dev bundle whose name ends with the given
+ * suffix.
+ */
+ private List getBundleFiles(String suffix) {
+ File buildFolder = new File(
+ new File(System.getProperty("user.dir", "."),
+ "target/" + Constants.DEV_BUNDLE_LOCATION),
+ "webapp/VAADIN/build");
+ Assert.assertTrue("The dev bundle should have been built into "
+ + buildFolder.getPath(), buildFolder.isDirectory());
+ return List.of(
+ buildFolder.listFiles((dir, name) -> name.endsWith(suffix)));
+ }
+
+ private String read(File file) throws IOException {
+ return FileUtils.readFileToString(file, StandardCharsets.UTF_8);
+ }
+
+}
diff --git a/flow-tests/test-express-build/test-dev-bundle-frontend-add-on/vite.config.ts b/flow-tests/test-express-build/test-dev-bundle-frontend-add-on/vite.config.ts
index fa078b4d2e1..9263febf2dc 100644
--- a/flow-tests/test-express-build/test-dev-bundle-frontend-add-on/vite.config.ts
+++ b/flow-tests/test-express-build/test-dev-bundle-frontend-add-on/vite.config.ts
@@ -23,7 +23,12 @@ function addCssToIndex(): PluginOption {
const customConfig: UserConfigFn = (env) => ({
// Here you can add custom Vite parameters
// https://vitejs.dev/config/
- plugins: [addCssToIndex()]
+ plugins: [addCssToIndex()],
+ build: {
+ // Emit separate .map files for the dev bundle so that
+ // DevBundleSourceMapsIT can verify that the build plugins keep them usable
+ sourcemap: true
+ }
});
export default overrideVaadinConfig(customConfig);
diff --git a/flow-tests/test-frontend/vite-production/src/test/java/com/vaadin/viteapp/SourceMapsIT.java b/flow-tests/test-frontend/vite-production/src/test/java/com/vaadin/viteapp/SourceMapsIT.java
index da482cfdedf..ca20d56a3fd 100644
--- a/flow-tests/test-frontend/vite-production/src/test/java/com/vaadin/viteapp/SourceMapsIT.java
+++ b/flow-tests/test-frontend/vite-production/src/test/java/com/vaadin/viteapp/SourceMapsIT.java
@@ -24,8 +24,8 @@
import org.junit.Assert;
import org.junit.Test;
-import tools.jackson.databind.JsonNode;
-import tools.jackson.databind.json.JsonMapper;
+
+import com.vaadin.flow.testutil.SourceMapTestUtil;
public class SourceMapsIT extends BundleAccess {
@@ -55,8 +55,8 @@ public void bundleSourceMapsPointToOriginalSources() throws Exception {
// Bundler runtime helpers are emitted without a sourcemap
continue;
}
- assertSourceMapUsable(bundle, JsonMapper.shared()
- .readTree(download(BUILD_PATH + matcher.group(1))));
+ SourceMapTestUtil.assertSourceMapUsable(bundle,
+ download(BUILD_PATH + matcher.group(1)));
checkedBundles++;
}
@@ -64,30 +64,6 @@ public void bundleSourceMapsPointToOriginalSources() throws Exception {
checkedBundles);
}
- private void assertSourceMapUsable(String bundle, JsonNode sourceMap) {
- JsonNode sources = sourceMap.get("sources");
- JsonNode sourcesContent = sourceMap.get("sourcesContent");
- Assert.assertNotEquals(bundle + " should have a sourcemap with sources",
- 0, sources.size());
- Assert.assertNotEquals(
- bundle + " should have a sourcemap with mappings", "",
- sourceMap.get("mappings").asString());
- Assert.assertEquals(
- bundle + " should have the contents of every source in its "
- + "sourcemap",
- sources.size(), sourcesContent.size());
- for (int i = 0; i < sources.size(); i++) {
- Assert.assertNotEquals(
- bundle + " should have a sourcemap referring to the "
- + "original files, was " + sources.get(i),
- "", sources.get(i).asString().trim());
- Assert.assertNotEquals(
- bundle + " should have the contents of "
- + sources.get(i).asString() + " in its sourcemap",
- "", sourcesContent.get(i).asString().trim());
- }
- }
-
/**
* Collects the given bundle and the bundles it imports, directly or through
* another bundle. Names that are not served are left out, as not every file