Skip to content
36 changes: 22 additions & 14 deletions flow-server/src/main/resources/vite.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 }) };
}
};
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* 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<String> 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<String> 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;
}
}
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
Original file line number Diff line number Diff line change
@@ -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<File> sourceMaps = getBundleFiles(".js.map");
Assert.assertFalse(
"The dev bundle should have been built with sourcemaps "
+ "enabled, see vite.config.ts",
sourceMaps.isEmpty());

List<String> 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<String> 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<File> 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);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -55,39 +55,15 @@ 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++;
}

Assert.assertNotEquals("No bundle with a sourcemap was found", 0,
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
Expand Down
Loading