diff --git a/flow-build-tools/src/main/resources/plugins/rollup-plugin-vaadin-i18n/rollup-plugin-vaadin-i18n.js b/flow-build-tools/src/main/resources/plugins/rollup-plugin-vaadin-i18n/rollup-plugin-vaadin-i18n.js index 4bac8f378be..9c7d4022bde 100644 --- a/flow-build-tools/src/main/resources/plugins/rollup-plugin-vaadin-i18n/rollup-plugin-vaadin-i18n.js +++ b/flow-build-tools/src/main/resources/plugins/rollup-plugin-vaadin-i18n/rollup-plugin-vaadin-i18n.js @@ -11,6 +11,14 @@ const chunkNameMarker = '__VAADIN_I18n_chunkName__'; const registerChunkImport = `import { i18n } from '@vaadin/hilla-react-i18n';\n`; const registerChunkCall = `await i18n.registerChunk('${chunkNameMarker}');\n`; +// Matches a registerChunk call with the chunk name marker in the rendered +// chunk, where the i18n binding may have been renamed by the bundler and the +// string quotes may have been rewritten. +const registerChunkCallPattern = new RegExp( + `(?:await\\s*)?[\\w$]+\\.registerChunk\\(\\s*(['"\`])${chunkNameMarker}\\1\\s*\\)\\s*;?`, + 'g' +); + /** * Vaadin Rollup/Vite plugin for automatic splitting of i18n bundles for Hilla * apps based on the chunks in the JS bundle output. @@ -125,34 +133,42 @@ export default function vaadinI18n(options = {}) { renderStart() { chunkKeySets.clear(); }, + renderChunk(code, chunk) { + const magicString = new MagicString(code); + // Extra imports are removed automatically from the final chunk, but + // there might be still multiple registerChunk calls originating from + // the modules using Hilla i18n. One such call per chunk is enough + // to load all the translations for the code below it, so let us keep + // the first one, with the marker replaced by the actual chunk name, + // and remove the rest. + let isFirstCall = true; + for (const match of code.matchAll(registerChunkCallPattern)) { + const start = match.index; + const end = start + match[0].length; + if (isFirstCall) { + magicString.overwrite(start, end, match[0].replace(chunkNameMarker, chunk.fileName)); + isFirstCall = false; + } else { + magicString.remove(start, end); + } + } + + if (!magicString.hasChanged()) { + // Nothing to rewrite in this chunk: leave it and its sourcemap as is + return null; + } + + // The rewriting happens in renderChunk, not in generateBundle, so that + // the bundler composes this sourcemap with the one it has for the chunk + // instead of the chunk ending up with a sourcemap without sources. + return { + code: magicString.toString(), + map: magicString.generateMap({ hires: true }) + }; + }, async generateBundle(_options, bundle) { for (const [fileName, chunk] of Object.entries(bundle)) { if (chunk.type === 'chunk') { - const magicString = new MagicString(chunk.code); - // Extra imports are removed automatically from the final chunk, but - // there might be still multiple registerChunk calls originating from - // the modules using Hilla i18n. One such call per chunk is enough - // to load all the translations for the code below it, so let us - // remove the duplicate calls. - let idx = 0; - let firstIdx = -1; - let searchIdx = 0; - while ((idx = magicString.toString().indexOf(registerChunkCall, searchIdx)) !== -1) { - if (firstIdx === -1) { - firstIdx = idx; - searchIdx = idx + registerChunkCall.length; - } else { - // Remove this occurrence - magicString.remove(idx, idx + registerChunkCall.length); - searchIdx = idx; // Don't advance, as string just got shorter - } - } - - // Replace the chunk name markers with the actual chunk name - magicString.replace(chunkNameMarker, fileName); - chunk.code = magicString.toString(); - chunk.map = magicString.generateMap({ hires: true }); - // Collect i18n translation keys from all modules of the chunk const chunkKeySet = new Set(); for (const id of chunk.moduleIds) { diff --git a/flow-tests/test-frontend/vite-production/package.json b/flow-tests/test-frontend/vite-production/package.json index 608c56cb52f..04ed1503324 100644 --- a/flow-tests/test-frontend/vite-production/package.json +++ b/flow-tests/test-frontend/vite-production/package.json @@ -7,6 +7,7 @@ "@polymer/polymer": "3.5.2", "@vaadin/bundles": "../vite-test-assets/packages/@vaadin/bundles", "@vaadin/common-frontend": "0.0.23", + "@vaadin/hilla-react-i18n": "../vite-test-assets/packages/@vaadin/hilla-react-i18n", "@vaadin/test-package-outside-npm": "file:../vite-test-assets/packages/@vaadin/test-package-outside-npm", "@vaadin/test-package2-outside-npm": "../vite-test-assets/packages/@vaadin/test-package2-outside-npm", "@vaadin/testscope-all": "../vite-test-assets/packages/@vaadin/testscope-all", diff --git a/flow-tests/test-frontend/vite-production/src/main/frontend/i18n-chunk-extra.js b/flow-tests/test-frontend/vite-production/src/main/frontend/i18n-chunk-extra.js new file mode 100644 index 00000000000..87ed30cc4be --- /dev/null +++ b/flow-tests/test-frontend/vite-production/src/main/frontend/i18n-chunk-extra.js @@ -0,0 +1,5 @@ +// A second module with translations in the same chunk, so that the build +// plugin has more than one registerChunk call to clean up. +import { key, translate } from '@vaadin/hilla-react-i18n'; + +window.i18nChunkExtraTranslation = translate(key`i18n.chunk.test.extra`); diff --git a/flow-tests/test-frontend/vite-production/src/main/frontend/i18n-chunk.js b/flow-tests/test-frontend/vite-production/src/main/frontend/i18n-chunk.js new file mode 100644 index 00000000000..c8acd69a0e4 --- /dev/null +++ b/flow-tests/test-frontend/vite-production/src/main/frontend/i18n-chunk.js @@ -0,0 +1,5 @@ +// Uses Hilla i18n translations so that the Vaadin i18n build plugin rewrites +// the chunk this module ends up in, adding the registerChunk call for it. +import { key, translate } from '@vaadin/hilla-react-i18n'; + +window.i18nChunkTranslation = translate(key`i18n.chunk.test`); diff --git a/flow-tests/test-frontend/vite-production/src/main/java/com/vaadin/viteapp/views/empty/MainView.java b/flow-tests/test-frontend/vite-production/src/main/java/com/vaadin/viteapp/views/empty/MainView.java index fecfdd2d8f0..42aa5ce222f 100644 --- a/flow-tests/test-frontend/vite-production/src/main/java/com/vaadin/viteapp/views/empty/MainView.java +++ b/flow-tests/test-frontend/vite-production/src/main/java/com/vaadin/viteapp/views/empty/MainView.java @@ -29,6 +29,8 @@ @JsModule("@vaadin/test-package-outside-npm/index.js") @JsModule("@vaadin/test-package2-outside-npm/index.js") @JsModule("./toplevelawait-main.js") +@JsModule("./i18n-chunk.js") +@JsModule("./i18n-chunk-extra.js") @CssImport("./image.css") @StyleSheet("styles/static-stylesheet.css") public class MainView extends Div { diff --git a/flow-tests/test-frontend/vite-production/src/test/java/com/vaadin/viteapp/BundleAccess.java b/flow-tests/test-frontend/vite-production/src/test/java/com/vaadin/viteapp/BundleAccess.java new file mode 100644 index 00000000000..0498a3ef1ea --- /dev/null +++ b/flow-tests/test-frontend/vite-production/src/test/java/com/vaadin/viteapp/BundleAccess.java @@ -0,0 +1,69 @@ +/* + * 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.viteapp; + +import java.io.FileNotFoundException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.commons.io.IOUtils; + +/** + * Reads the files of the production bundle from the running application, for + * the tests that assert on what the frontend build produced. + */ +public class BundleAccess { + + protected static final String BUILD_PATH = "/VAADIN/build/"; + + private static final Pattern ENTRY_BUNDLE = Pattern + .compile(".* src=\"\\./VAADIN/build/([^\"]*)\".*", Pattern.DOTALL); + + protected String getRootURL() { + return "http://localhost:8888"; + } + + /** + * Returns the name of the bundle that index.html loads. + */ + protected String getJsBundleName() throws Exception { + Matcher matcher = ENTRY_BUNDLE.matcher(download("/index.html")); + if (!matcher.matches()) { + throw new IllegalStateException("No script found"); + } + return matcher.group(1); + } + + protected String download(String path) throws Exception { + return IOUtils.toString(new URL(getRootURL() + path), + StandardCharsets.UTF_8); + } + + /** + * Downloads the given path, or returns {@code null} when the application + * does not serve it. + */ + protected String downloadIfAvailable(String path) throws Exception { + try { + return download(path); + } catch (FileNotFoundException e) { + return null; + } + } + +} diff --git a/flow-tests/test-frontend/vite-production/src/test/java/com/vaadin/viteapp/I18nChunkIT.java b/flow-tests/test-frontend/vite-production/src/test/java/com/vaadin/viteapp/I18nChunkIT.java new file mode 100644 index 00000000000..7db4d5d0386 --- /dev/null +++ b/flow-tests/test-frontend/vite-production/src/test/java/com/vaadin/viteapp/I18nChunkIT.java @@ -0,0 +1,73 @@ +/* + * 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.viteapp; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.junit.Assert; +import org.junit.Test; + +/** + * Tests the chunk rewriting of the Vaadin i18n build plugin, which the + * application triggers with the Hilla translations in {@code i18n-chunk.js}. + */ +public class I18nChunkIT extends BundleAccess { + + private static final String CHUNK_NAME_MARKER = "__VAADIN_I18n_chunkName__"; + + private static final Pattern REGISTER_CHUNK = Pattern + .compile("registerChunk\\(\\s*[\"'`]([^\"'`]+)[\"'`]\\s*\\)"); + + @Test + public void chunkRegistersItselfUnderItsOwnName() throws Exception { + String bundle = getBundleWithTranslations(); + Matcher matcher = REGISTER_CHUNK.matcher(download(BUILD_PATH + bundle)); + + Assert.assertTrue(bundle + " should register itself for translations", + matcher.find()); + Assert.assertEquals( + "The chunk should be registered under the name it is served " + + "with, which is the name the i18n metadata uses", + BUILD_PATH.substring(1) + bundle, matcher.group(1)); + Assert.assertFalse( + bundle + " should not have more than one registerChunk call", + matcher.find()); + } + + /** + * Finds the bundle that has the translations of the application, by the + * marker the i18n plugin replaces with the name of the chunk. + */ + private String getBundleWithTranslations() throws Exception { + String entryBundle = getJsBundleName(); + Matcher matcher = Pattern.compile("[\"']\\./([^\"']+\\.js)[\"']") + .matcher(download(BUILD_PATH + entryBundle)); + while (matcher.find()) { + String bundle = matcher.group(1); + String contents = downloadIfAvailable(BUILD_PATH + bundle); + if (contents != null && contents.contains("i18n.chunk.test")) { + Assert.assertFalse( + bundle + " should have the chunk name marker replaced", + contents.contains(CHUNK_NAME_MARKER)); + return bundle; + } + } + throw new IllegalStateException( + "No bundle with the translations of the application found"); + } + +} 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 new file mode 100644 index 00000000000..da482cfdedf --- /dev/null +++ b/flow-tests/test-frontend/vite-production/src/test/java/com/vaadin/viteapp/SourceMapsIT.java @@ -0,0 +1,114 @@ +/* + * 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.viteapp; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.junit.Assert; +import org.junit.Test; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.json.JsonMapper; + +public class SourceMapsIT extends BundleAccess { + + private static final Pattern IMPORTED_BUNDLE = Pattern + .compile("[\"']\\./([^\"']+\\.js)[\"']"); + private static final Pattern SOURCE_MAPPING_URL = Pattern + .compile("//# sourceMappingURL=(\\S+)"); + + /** + * A build plugin that rewrites a chunk must chain its sourcemap onto the + * one the bundler already has for that chunk, otherwise the emitted .map + * file ends up without the original sources and the browser cannot map the + * bundle back to them. + */ + @Test + public void bundleSourceMapsPointToOriginalSources() throws Exception { + String entryBundle = getJsBundleName(); + int checkedBundles = 0; + + for (String bundle : getBundles(entryBundle)) { + String contents = download(BUILD_PATH + bundle); + Matcher matcher = SOURCE_MAPPING_URL.matcher(contents); + if (!matcher.find()) { + Assert.assertNotEquals( + entryBundle + " should refer to an emitted sourcemap", + entryBundle, bundle); + // Bundler runtime helpers are emitted without a sourcemap + continue; + } + assertSourceMapUsable(bundle, JsonMapper.shared() + .readTree(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 + * name in a bundle is an emitted chunk. + */ + private Set getBundles(String entryBundle) throws Exception { + Set bundles = new LinkedHashSet<>(); + Deque pending = new ArrayDeque<>(); + pending.add(entryBundle); + while (!pending.isEmpty()) { + String bundle = pending.remove(); + String contents = downloadIfAvailable(BUILD_PATH + bundle); + if (contents == null || !bundles.add(bundle)) { + continue; + } + Matcher matcher = IMPORTED_BUNDLE.matcher(contents); + while (matcher.find()) { + pending.add(matcher.group(1)); + } + } + return bundles; + } + +} diff --git a/flow-tests/test-frontend/vite-production/vite.config.ts b/flow-tests/test-frontend/vite-production/vite.config.ts index 97ff85996eb..ef9c1b3b132 100644 --- a/flow-tests/test-frontend/vite-production/vite.config.ts +++ b/flow-tests/test-frontend/vite-production/vite.config.ts @@ -4,6 +4,11 @@ import { overrideVaadinConfig } from './vite.generated.ts'; const customConfig: UserConfigFn = (env) => ({ // Here you can add custom Vite parameters // https://vitejs.dev/config/ + build: { + // Emit separate .map files for the production bundle so that + // SourceMapsIT can verify that the build plugins keep them usable + sourcemap: true + } }); export default overrideVaadinConfig(customConfig); diff --git a/flow-tests/test-frontend/vite-test-assets/packages/@vaadin/hilla-react-i18n/index.js b/flow-tests/test-frontend/vite-test-assets/packages/@vaadin/hilla-react-i18n/index.js new file mode 100644 index 00000000000..54559d094c7 --- /dev/null +++ b/flow-tests/test-frontend/vite-test-assets/packages/@vaadin/hilla-react-i18n/index.js @@ -0,0 +1,17 @@ +// Minimal stand-in for the Hilla i18n runtime. The Vaadin i18n build plugin +// looks for imports of this package and adds an i18n.registerChunk call to +// every chunk that uses translations, so the tests need the package to exist +// but not to load any actual translations. +export const i18n = { + async registerChunk(chunkName) { + window.registeredI18nChunks = [...(window.registeredI18nChunks ?? []), chunkName]; + } +}; + +export function key(strings) { + return strings[0]; +} + +export function translate(translationKey) { + return `translated:${translationKey}`; +} diff --git a/flow-tests/test-frontend/vite-test-assets/packages/@vaadin/hilla-react-i18n/package.json b/flow-tests/test-frontend/vite-test-assets/packages/@vaadin/hilla-react-i18n/package.json new file mode 100644 index 00000000000..75596f4b525 --- /dev/null +++ b/flow-tests/test-frontend/vite-test-assets/packages/@vaadin/hilla-react-i18n/package.json @@ -0,0 +1,10 @@ +{ + "name": "@vaadin/hilla-react-i18n", + "version": "1.0.0", + "description": "Stub of the Hilla i18n runtime, used to exercise the Vaadin i18n build plugin in the tests", + "main": "index.js", + "module": "index.js", + "type": "module", + "author": "", + "license": "Apache-2.0" +}