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