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