diff --git a/flow-server/src/main/resources/vite.generated.ts b/flow-server/src/main/resources/vite.generated.ts
index ad4a30d4adf..836ab3a14c8 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,32 @@ function preserveUsageStats() {
return {
name: 'vaadin:preserve-usage-stats',
+ // This hook sees every module of the bundle, and a module handed back
+ // without a sourcemap is dropped from the map of the chunk it ends up in.
+ // Returning null for the modules that are not rewritten therefore keeps
+ // the maps the bundler has for them, and the one module that is rewritten
+ // gets a map of its own.
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.js b/flow-tests/test-express-build/test-dev-bundle-frontend-add-on/src/main/frontend/vaadin-usage-statistics.js
new file mode 100644
index 00000000000..12372f856d5
--- /dev/null
+++ b/flow-tests/test-express-build/test-dev-bundle-frontend-add-on/src/main/frontend/vaadin-usage-statistics.js
@@ -0,0 +1,14 @@
+// Stands in for the @vaadin/vaadin-usage-statistics package, which a
+// Flow-only application does not pull in. The build plugin that keeps the
+// comment below in the bundle matches on the file name and on the marker, so
+// this file is enough to run the build through that plugin.
+/** vaadin-dev-mode:start
+(function () {
+ window.Vaadin = window.Vaadin || {};
+ window.Vaadin.registrations = window.Vaadin.registrations || [];
+})();
+vaadin-dev-mode:end **/
+
+// Keeps the module, and with it the comment, out of reach of tree shaking
+window.Vaadin = window.Vaadin || {};
+window.Vaadin.usageStatisticsStandIn = true;
diff --git a/flow-tests/test-express-build/test-dev-bundle-frontend-add-on/src/main/java/com/vaadin/flow/frontend/LitView.java b/flow-tests/test-express-build/test-dev-bundle-frontend-add-on/src/main/java/com/vaadin/flow/frontend/LitView.java
index e23d7b2ed06..e0f1249b261 100644
--- a/flow-tests/test-express-build/test-dev-bundle-frontend-add-on/src/main/java/com/vaadin/flow/frontend/LitView.java
+++ b/flow-tests/test-express-build/test-dev-bundle-frontend-add-on/src/main/java/com/vaadin/flow/frontend/LitView.java
@@ -23,6 +23,9 @@
@Route("com.vaadin.flow.frontend.LitView")
@Tag("lit-view")
@JsModule("./views/lit-view.ts")
+// Brings the usage statistics stand-in into the bundle, so that the build runs
+// the plugin that keeps its vaadin-dev-mode comment, see DevBundleSourceMapsIT
+@JsModule("./vaadin-usage-statistics.js")
public class LitView extends LitTemplate {
}
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..db41aea17c3
--- /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,137 @@
+/*
+ * 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 org.apache.commons.io.FileUtils;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.openqa.selenium.By;
+
+import com.vaadin.flow.server.Constants;
+import com.vaadin.flow.testutil.ChromeBrowserTest;
+import com.vaadin.flow.testutil.SourceMapTestUtil;
+
+/**
+ * A build plugin that hands back the code of a module without a sourcemap drops
+ * that module from the map of the chunk it ends up in. The plugins of the dev
+ * bundle build see every module of the bundle, so one of them doing that leaves
+ * the emitted .map files without sources or mappings and the browser cannot map
+ * the bundle back to the original files.
+ *
+ * @see vaadin/flow#16679
+ */
+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.js";
+
+ @Override
+ protected String getTestPath() {
+ return "/view/com.vaadin.flow.frontend.LitView";
+ }
+
+ @Before
+ public void init() {
+ // The dev bundle is built when the application is first opened
+ open();
+ waitForElementPresent(By.tagName("lit-view"));
+ }
+
+ @Test
+ public void devBundleSourceMaps_pointToOriginalSources()
+ throws IOException {
+ List sources = new ArrayList<>();
+ List sourceMaps = getDevBundleSourceMaps();
+ for (File sourceMap : sourceMaps) {
+ sources.addAll(SourceMapTestUtil.assertSourceMapUsable(
+ sourceMap.getName(), read(sourceMap)));
+ }
+
+ assertHasSource(sources, LIT_VIEW_SOURCE);
+ assertHasSource(sources, USAGE_STATISTICS_SOURCE);
+ }
+
+ /**
+ * The plugin that keeps the usage statistics comment rewrites the module it
+ * is in, which is the one case where the plugin has to produce a sourcemap
+ * of its own instead of leaving the module alone.
+ */
+ @Test
+ public void usageStatisticsComment_isRewrittenInTheBundle()
+ throws IOException {
+ File chunk = getDevBundleChunkOf("vaadin-dev-mode:start");
+ String contents = read(chunk);
+
+ Assert.assertTrue(
+ chunk.getName() + " should have the usage statistics comment "
+ + "rewritten so that it is kept in the bundle",
+ contents.contains("/*! vaadin-dev-mode:start"));
+ Assert.assertFalse(
+ chunk.getName() + " should no longer have the original "
+ + "usage statistics comment",
+ contents.contains("/** vaadin-dev-mode:start"));
+ }
+
+ 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)));
+ }
+
+ private List getDevBundleSourceMaps() {
+ List sourceMaps = List.of(getDevBundleBuildFolder()
+ .listFiles((dir, name) -> name.endsWith(".js.map")));
+ Assert.assertFalse(
+ "The dev bundle should have been built with sourcemaps "
+ + "enabled, see vite.config.ts",
+ sourceMaps.isEmpty());
+ return sourceMaps;
+ }
+
+ private File getDevBundleChunkOf(String marker) throws IOException {
+ List chunks = List.of(getDevBundleBuildFolder()
+ .listFiles((dir, name) -> name.endsWith(".js")));
+ for (File chunk : chunks) {
+ if (read(chunk).contains(marker)) {
+ return chunk;
+ }
+ }
+ throw new AssertionError("No chunk of the dev bundle contains '"
+ + marker + "', looked at " + chunks);
+ }
+
+ private File getDevBundleBuildFolder() {
+ File baseDir = new File(System.getProperty("user.dir", "."));
+ File buildFolder = new File(baseDir, "target/"
+ + Constants.DEV_BUNDLE_LOCATION + "/webapp/VAADIN/build");
+ Assert.assertTrue("The dev bundle should have been built into "
+ + buildFolder.getPath(), buildFolder.isDirectory());
+ return buildFolder;
+ }
+
+ 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