diff --git a/integration-tests/common-test-code/src/main/java/com/vaadin/flow/quarkus/it/i18n/TranslationView.java b/integration-tests/common-test-code/src/main/java/com/vaadin/flow/quarkus/it/i18n/TranslationView.java new file mode 100644 index 0000000..45e73d3 --- /dev/null +++ b/integration-tests/common-test-code/src/main/java/com/vaadin/flow/quarkus/it/i18n/TranslationView.java @@ -0,0 +1,94 @@ +/* + * Copyright 2000-2024 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.quarkus.it.i18n; + +import java.util.Locale; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; + +import com.vaadin.flow.component.AttachEvent; +import com.vaadin.flow.component.UI; +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.html.Span; +import com.vaadin.flow.i18n.I18NProvider; +import com.vaadin.flow.internal.LocaleUtil; +import com.vaadin.flow.router.Route; + +@Route(value = "translations") +public class TranslationView extends Div { + + public static final String TEST_VIEW_ID = "TranslationView"; + public static final String LOCALES_ID = "available-locales"; + + private Span dynamic; + + public TranslationView() { + setId(TEST_VIEW_ID); + + Span defaultLang = new Span(getTranslation("label", Locale.ENGLISH)); + defaultLang.setId("english"); + Span german = new Span(getTranslation("label", Locale.GERMAN)); + german.setId("german"); + Span germany = new Span(getTranslation("label", Locale.GERMANY)); + germany.setId("germany"); + Span finnish = new Span( + getTranslation("label", new Locale("fi", "FI"))); + finnish.setId("finnish"); + Span french = new Span(getTranslation("label", Locale.FRANCE)); + french.setId("french"); + Span japanese = new Span(getTranslation("label", Locale.JAPAN)); + japanese.setId("japanese"); + + Optional i18NProvider = LocaleUtil.getI18NProvider(); + if (i18NProvider.isPresent()) { + add(new Span("Available translation locales:")); + StringBuilder locales = new StringBuilder(); + for (Locale locale : i18NProvider.get().getProvidedLocales()) { + if (locales.length() > 0) { + locales.append(", "); + } + locales.append(locale.toString()); + } + Span localeSpan = new Span(locales.toString()); + localeSpan.setId(LOCALES_ID); + add(localeSpan, new Div()); + } + dynamic = new Span("waiting"); + dynamic.setId("dynamic"); + + add(defaultLang, new Div(), german, new Div(), germany, new Div(), + finnish, new Div(), french, new Div(), japanese, new Div(), + dynamic); + } + + @Override + protected void onAttach(AttachEvent event) { + UI ui = event.getUI(); + ui.setPollInterval(100); + CompletableFuture.runAsync(() -> { + try { + Thread.sleep(50); + } catch (Exception e) { + e.printStackTrace(); + } finally { + ui.access(() -> dynamic + .setText(getTranslation("label", Locale.FRANCE))); + ui.setPollInterval(-1); + } + }); + + } +} diff --git a/integration-tests/common-test-code/src/main/resources/vaadin-i18n/translations.properties b/integration-tests/common-test-code/src/main/resources/vaadin-i18n/translations.properties new file mode 100644 index 0000000..d8e29a9 --- /dev/null +++ b/integration-tests/common-test-code/src/main/resources/vaadin-i18n/translations.properties @@ -0,0 +1 @@ +label=Default diff --git a/integration-tests/common-test-code/src/main/resources/vaadin-i18n/translations_de.properties b/integration-tests/common-test-code/src/main/resources/vaadin-i18n/translations_de.properties new file mode 100644 index 0000000..5afe465 --- /dev/null +++ b/integration-tests/common-test-code/src/main/resources/vaadin-i18n/translations_de.properties @@ -0,0 +1 @@ +label=Deutsch diff --git a/integration-tests/common-test-code/src/main/resources/vaadin-i18n/translations_fi_FI.properties b/integration-tests/common-test-code/src/main/resources/vaadin-i18n/translations_fi_FI.properties new file mode 100644 index 0000000..38e68f8 --- /dev/null +++ b/integration-tests/common-test-code/src/main/resources/vaadin-i18n/translations_fi_FI.properties @@ -0,0 +1 @@ +label=Suomi diff --git a/integration-tests/common-test-code/src/main/resources/vaadin-i18n/translations_fr_FR.properties b/integration-tests/common-test-code/src/main/resources/vaadin-i18n/translations_fr_FR.properties new file mode 100644 index 0000000..ef14ff6 --- /dev/null +++ b/integration-tests/common-test-code/src/main/resources/vaadin-i18n/translations_fr_FR.properties @@ -0,0 +1 @@ +label=français diff --git a/integration-tests/common-test-code/src/main/resources/vaadin-i18n/translations_ja_JP.properties b/integration-tests/common-test-code/src/main/resources/vaadin-i18n/translations_ja_JP.properties new file mode 100644 index 0000000..16d1921 --- /dev/null +++ b/integration-tests/common-test-code/src/main/resources/vaadin-i18n/translations_ja_JP.properties @@ -0,0 +1 @@ +label=日本語 diff --git a/integration-tests/common-test-code/src/test/java/com/vaadin/flow/quarkus/it/TranslationIT.java b/integration-tests/common-test-code/src/test/java/com/vaadin/flow/quarkus/it/TranslationIT.java new file mode 100644 index 0000000..df20062 --- /dev/null +++ b/integration-tests/common-test-code/src/test/java/com/vaadin/flow/quarkus/it/TranslationIT.java @@ -0,0 +1,75 @@ +/* + * Copyright 2000-2024 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.quarkus.it; + +import io.quarkus.test.junit.QuarkusIntegrationTest; +import org.junit.Assert; +import org.junit.Test; + +import com.vaadin.flow.component.html.testbench.SpanElement; +import com.vaadin.flow.quarkus.it.i18n.TranslationView; +import com.vaadin.flow.test.AbstractChromeIT; +import com.vaadin.flow.testutil.ChromeBrowserTest; + +@QuarkusIntegrationTest +public class TranslationIT extends AbstractChromeIT { + @Override + protected String getTestPath() { + return "/translations"; + } + + @Test + public void translationFilesExist_defaultI18NInstantiated_languagesWork() { + open(); + + String locales = $(SpanElement.class).id(TranslationView.LOCALES_ID) + .getText(); + Assert.assertTrue("Couldn't verify German locale", + locales.contains("de")); + Assert.assertTrue("Couldn't verify Finnish locale", + locales.contains("fi_FI")); + Assert.assertTrue("Couldn't verify French locale", + locales.contains("fr_FR")); + Assert.assertTrue("Couldn't verify Japanese locale", + locales.contains("ja_JP")); + + Assert.assertEquals("Default", + $(SpanElement.class).id("english").getText()); + Assert.assertEquals("Deutsch", + $(SpanElement.class).id("german").getText()); + Assert.assertEquals("Deutsch", + $(SpanElement.class).id("germany").getText()); + Assert.assertEquals("Suomi", + $(SpanElement.class).id("finnish").getText()); + Assert.assertEquals("français", + $(SpanElement.class).id("french").getText()); + Assert.assertEquals("日本語", + $(SpanElement.class).id("japanese").getText()); + } + + @Test + public void translationFilesExist_defaultI18NInstantiated_updateFromExternalThreadWorks() { + open(); + + waitUntilNot(driver -> $(SpanElement.class).id("dynamic").getText() + .equals("waiting")); + + Assert.assertEquals( + "Dynamic update from thread should have used correct bundle.", + "français", $(SpanElement.class).id("dynamic").getText()); + } +} diff --git a/integration-tests/production/vite.generated.ts b/integration-tests/production/vite.generated.ts index 0c90ddf..6655bcc 100644 --- a/integration-tests/production/vite.generated.ts +++ b/integration-tests/production/vite.generated.ts @@ -5,13 +5,24 @@ * This file will be overwritten on every run. Any custom changes should be made to vite.config.ts */ import path from 'path'; -import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'fs'; +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs'; +import { createHash } from 'crypto'; import * as net from 'net'; import { processThemeResources } from './target/plugins/application-theme-plugin/theme-handle.js'; import { rewriteCssUrls } from './target/plugins/theme-loader/theme-loader-utils.js'; +import { addFunctionComponentSourceLocationBabel } from './target/plugins/react-function-location-plugin/react-function-location-plugin.js'; import settings from './target/vaadin-dev-server-settings.json'; -import { defineConfig, mergeConfig, PluginOption, ResolvedConfig, UserConfigFn, OutputOptions, AssetInfo, ChunkInfo } from 'vite'; +import { + AssetInfo, + ChunkInfo, + defineConfig, + mergeConfig, + OutputOptions, + PluginOption, + ResolvedConfig, + UserConfigFn +} from 'vite'; import { getManifest } from 'workbox-build'; import * as rollup from 'rollup'; @@ -20,17 +31,35 @@ import replace from '@rollup/plugin-replace'; import checker from 'vite-plugin-checker'; import postcssLit from './target/plugins/rollup-plugin-postcss-lit-custom/rollup-plugin-postcss-lit.js'; +import { createRequire } from 'module'; + +import { visualizer } from 'rollup-plugin-visualizer'; +import reactPlugin from '@vitejs/plugin-react'; + + + +// Make `require` compatible with ES modules +const require = createRequire(import.meta.url); + const appShellUrl = '.'; const frontendFolder = path.resolve(__dirname, settings.frontendFolder); const themeFolder = path.resolve(frontendFolder, settings.themeFolder); -const statsFolder = path.resolve(__dirname, settings.statsOutput); const frontendBundleFolder = path.resolve(__dirname, settings.frontendBundleOutput); +const devBundleFolder = path.resolve(__dirname, settings.devBundleOutput); +const devBundle = !!process.env.devBundle; const jarResourcesFolder = path.resolve(__dirname, settings.jarResourcesFolder); -const generatedFlowImportsFolder = path.resolve(__dirname, settings.generatedFlowImportsFolder); const themeResourceFolder = path.resolve(__dirname, settings.themeResourceFolder); +const projectPackageJsonFile = path.resolve(__dirname, 'package.json'); +const buildOutputFolder = devBundle ? devBundleFolder : frontendBundleFolder; +const statsFolder = path.resolve(__dirname, devBundle ? settings.devBundleStatsOutput : settings.statsOutput); const statsFile = path.resolve(statsFolder, 'stats.json'); +const bundleSizeFile = path.resolve(statsFolder, 'bundle-size.html'); +const nodeModulesFolder = path.resolve(__dirname, 'node_modules'); +const webComponentTags = ''; + +const projectIndexHtml = path.resolve(frontendFolder, 'index.html'); const projectStaticAssetsFolders = [ path.resolve(__dirname, 'src', 'main', 'resources', 'META-INF', 'resources'), @@ -43,11 +72,14 @@ const themeProjectFolders = projectStaticAssetsFolders.map((folder) => path.reso const themeOptions = { devMode: false, + useDevBundle: devBundle, // The following matches folder 'frontend/generated/themes/' // (not 'frontend/themes') for theme in JAR that is copied there themeResourceFolder: path.resolve(themeResourceFolder, settings.themeFolder), themeProjectFolders: themeProjectFolders, - projectStaticAssetsOutputFolder: path.resolve(__dirname, settings.staticOutput), + projectStaticAssetsOutputFolder: devBundle + ? path.resolve(devBundleFolder, '../assets') + : path.resolve(__dirname, settings.staticOutput), frontendGeneratedFolder: path.resolve(frontendFolder, settings.generatedFolder) }; @@ -72,42 +104,48 @@ function injectManifestToSWPlugin(): rollup.Plugin { async transform(code, id) { if (/sw\.(ts|js)$/.test(id)) { const { manifestEntries } = await getManifest({ - globDirectory: frontendBundleFolder, + globDirectory: buildOutputFolder, globPatterns: ['**/*'], globIgnores: ['**/*.br'], manifestTransforms: [rewriteManifestIndexHtmlUrl], - maximumFileSizeToCacheInBytes: 100 * 1024 * 1024, // 100mb, + maximumFileSizeToCacheInBytes: 100 * 1024 * 1024 // 100mb, }); return code.replace('self.__WB_MANIFEST', JSON.stringify(manifestEntries)); } } - } + }; } function buildSWPlugin(opts): PluginOption { let config: ResolvedConfig; const devMode = opts.devMode; - const swObj = {} + const swObj = {}; async function build(action: 'generate' | 'write', additionalPlugins: rollup.Plugin[] = []) { const includedPluginNames = [ - 'alias', - 'vite:resolve', 'vite:esbuild', 'rollup-plugin-dynamic-import-variables', 'vite:esbuild-transpile', - 'vite:terser', - ] + 'vite:terser' + ]; const plugins: rollup.Plugin[] = config.plugins.filter((p) => { - return includedPluginNames.includes(p.name) + return includedPluginNames.includes(p.name); }); + const resolver = config.createResolver(); + const resolvePlugin: rollup.Plugin = { + name: 'resolver', + resolveId(source, importer, _options) { + return resolver(source, importer); + } + }; + plugins.unshift(resolvePlugin); // Put resolve first plugins.push( replace({ values: { 'process.env.NODE_ENV': JSON.stringify(config.mode), - ...config.define, + ...config.define }, preventAssignment: true }) @@ -122,11 +160,11 @@ function buildSWPlugin(opts): PluginOption { try { return await bundle[action]({ - file: path.resolve(frontendBundleFolder, 'sw.js'), + file: path.resolve(buildOutputFolder, 'sw.js'), format: 'es', exports: 'none', sourcemap: config.command === 'serve' || config.build.sourcemap, - inlineDynamicImports: true, + inlineDynamicImports: true }); } finally { await bundle.close(); @@ -143,7 +181,7 @@ function buildSWPlugin(opts): PluginOption { if (devMode) { const { output } = await build('generate'); swObj.code = output[0].code; - swObj.map = output[0].map; + swObj.map = output[0].map; } }, async load(id) { @@ -157,23 +195,37 @@ function buildSWPlugin(opts): PluginOption { } }, async closeBundle() { - await build('write', [ - injectManifestToSWPlugin(), - brotli(), - ]); + if (!devMode) { + await build('write', [injectManifestToSWPlugin(), brotli()]); + } } - } + }; } function statsExtracterPlugin(): PluginOption { + function collectThemeJsonsInFrontend(themeJsonContents: Record, themeName: string) { + const themeJson = path.resolve(frontendFolder, settings.themeFolder, themeName, 'theme.json'); + if (existsSync(themeJson)) { + const themeJsonContent = readFileSync(themeJson, { encoding: 'utf-8' }).replace(/\r\n/g, '\n'); + themeJsonContents[themeName] = themeJsonContent; + const themeJsonObject = JSON.parse(themeJsonContent); + if (themeJsonObject.parent) { + collectThemeJsonsInFrontend(themeJsonContents, themeJsonObject.parent); + } + } + } + return { name: 'vaadin:stats', enforce: 'post', async writeBundle(options: OutputOptions, bundle: { [fileName: string]: AssetInfo | ChunkInfo }) { const modules = Object.values(bundle).flatMap((b) => (b.modules ? Object.keys(b.modules) : [])); - const nodeModulesFolders = modules.filter((id) => id.includes('node_modules')); + const nodeModulesFolders = modules + .map((id) => id.replace(/\\/g, '/')) + .filter((id) => id.startsWith(nodeModulesFolder.replace(/\\/g, '/'))) + .map((id) => id.substring(nodeModulesFolder.length + 1)); const npmModules = nodeModulesFolders - .map((id) => id.replace(/.*node_modules./, '')) + .map((id) => id.replace(/\\/g, '/')) .map((id) => { const parts = id.split('/'); if (id.startsWith('@')) { @@ -184,9 +236,176 @@ function statsExtracterPlugin(): PluginOption { }) .sort() .filter((value, index, self) => self.indexOf(value) === index); + const npmModuleAndVersion = Object.fromEntries(npmModules.map((module) => [module, getVersion(module)])); + const cvdls = Object.fromEntries( + npmModules + .filter((module) => getCvdlName(module) != null) + .map((module) => [module, { name: getCvdlName(module), version: getVersion(module) }]) + ); mkdirSync(path.dirname(statsFile), { recursive: true }); - writeFileSync(statsFile, JSON.stringify({ npmModules }, null, 1)); + const projectPackageJson = JSON.parse(readFileSync(projectPackageJsonFile, { encoding: 'utf-8' })); + + const entryScripts = Object.values(bundle) + .filter((bundle) => bundle.isEntry) + .map((bundle) => bundle.fileName); + + const generatedIndexHtml = path.resolve(buildOutputFolder, 'index.html'); + const customIndexData: string = readFileSync(projectIndexHtml, { encoding: 'utf-8' }); + const generatedIndexData: string = readFileSync(generatedIndexHtml, { + encoding: 'utf-8' + }); + + const customIndexRows = new Set(customIndexData.split(/[\r\n]/).filter((row) => row.trim() !== '')); + const generatedIndexRows = generatedIndexData.split(/[\r\n]/).filter((row) => row.trim() !== ''); + + const rowsGenerated: string[] = []; + generatedIndexRows.forEach((row) => { + if (!customIndexRows.has(row)) { + rowsGenerated.push(row); + } + }); + + //After dev-bundle build add used Flow frontend imports JsModule/JavaScript/CssImport + + const parseImports = (filename: string, result: Set): void => { + const content: string = readFileSync(filename, { encoding: 'utf-8' }); + const lines = content.split('\n'); + const staticImports = lines + .filter((line) => line.startsWith('import ')) + .map((line) => line.substring(line.indexOf("'") + 1, line.lastIndexOf("'"))) + .map((line) => (line.includes('?') ? line.substring(0, line.lastIndexOf('?')) : line)); + const dynamicImports = lines + .filter((line) => line.includes('import(')) + .map((line) => line.replace(/.*import\(/, '')) + .map((line) => line.split(/'/)[1]) + .map((line) => (line.includes('?') ? line.substring(0, line.lastIndexOf('?')) : line)); + + staticImports.forEach((staticImport) => result.add(staticImport)); + + dynamicImports.map((dynamicImport) => { + const importedFile = path.resolve(path.dirname(filename), dynamicImport); + parseImports(importedFile, result); + }); + }; + + const generatedImportsSet = new Set(); + parseImports( + path.resolve(themeOptions.frontendGeneratedFolder, 'flow', 'generated-flow-imports.js'), + generatedImportsSet + ); + const generatedImports = Array.from(generatedImportsSet).sort(); + + const frontendFiles: Record = {}; + + const projectFileExtensions = ['.js', '.js.map', '.ts', '.ts.map', '.tsx', '.tsx.map', '.css', '.css.map']; + + const isThemeComponentsResource = (id: string) => + id.startsWith(themeOptions.frontendGeneratedFolder.replace(/\\/g, '/')) + && id.match(/.*\/jar-resources\/themes\/[^\/]+\/components\//); + + const isGeneratedWebComponentResource = (id: string) => + id.startsWith(themeOptions.frontendGeneratedFolder.replace(/\\/g, '/')) + && id.match(/.*\/flow\/web-components\//); + + const isFrontendResourceCollected = (id: string) => + !id.startsWith(themeOptions.frontendGeneratedFolder.replace(/\\/g, '/')) + || isThemeComponentsResource(id) + || isGeneratedWebComponentResource(id); + + // collects project's frontend resources in frontend folder, excluding + // 'generated' sub-folder, except for legacy shadow DOM stylesheets + // packaged in `theme/components/` folder + // and generated web component resources in `flow/web-components` folder. + modules + .map((id) => id.replace(/\\/g, '/')) + .filter((id) => id.startsWith(frontendFolder.replace(/\\/g, '/'))) + .filter(isFrontendResourceCollected) + .map((id) => id.substring(frontendFolder.length + 1)) + .map((line: string) => (line.includes('?') ? line.substring(0, line.lastIndexOf('?')) : line)) + .forEach((line: string) => { + // \r\n from windows made files may be used so change to \n + const filePath = path.resolve(frontendFolder, line); + if (projectFileExtensions.includes(path.extname(filePath))) { + const fileBuffer = readFileSync(filePath, { encoding: 'utf-8' }).replace(/\r\n/g, '\n'); + frontendFiles[line] = createHash('sha256').update(fileBuffer, 'utf8').digest('hex'); + } + }); + + // collects frontend resources from the JARs + generatedImports + .filter((line: string) => line.includes('generated/jar-resources')) + .forEach((line: string) => { + let filename = line.substring(line.indexOf('generated')); + // \r\n from windows made files may be used ro remove to be only \n + const fileBuffer = readFileSync(path.resolve(frontendFolder, filename), { encoding: 'utf-8' }).replace( + /\r\n/g, + '\n' + ); + const hash = createHash('sha256').update(fileBuffer, 'utf8').digest('hex'); + + const fileKey = line.substring(line.indexOf('jar-resources/') + 14); + frontendFiles[fileKey] = hash; + }); + // collects and hash rest of the Frontend resources excluding files in /generated/ and /themes/ + // and files already in frontendFiles. + let frontendFolderAlias = "Frontend"; + generatedImports + .filter((line: string) => line.startsWith(frontendFolderAlias + '/')) + .filter((line: string) => !line.startsWith(frontendFolderAlias + '/generated/')) + .filter((line: string) => !line.startsWith(frontendFolderAlias + '/themes/')) + .map((line) => line.substring(frontendFolderAlias.length + 1)) + .filter((line: string) => !frontendFiles[line]) + .forEach((line: string) => { + const filePath = path.resolve(frontendFolder, line); + if (projectFileExtensions.includes(path.extname(filePath)) && existsSync(filePath)) { + const fileBuffer = readFileSync(filePath, { encoding: 'utf-8' }).replace(/\r\n/g, '\n'); + frontendFiles[line] = createHash('sha256').update(fileBuffer, 'utf8').digest('hex'); + } + }); + // If a index.ts exists hash it to be able to see if it changes. + if (existsSync(path.resolve(frontendFolder, 'index.ts'))) { + const fileBuffer = readFileSync(path.resolve(frontendFolder, 'index.ts'), { encoding: 'utf-8' }).replace( + /\r\n/g, + '\n' + ); + frontendFiles[`index.ts`] = createHash('sha256').update(fileBuffer, 'utf8').digest('hex'); + } + + const themeJsonContents: Record = {}; + const themesFolder = path.resolve(jarResourcesFolder, 'themes'); + if (existsSync(themesFolder)) { + readdirSync(themesFolder).forEach((themeFolder) => { + const themeJson = path.resolve(themesFolder, themeFolder, 'theme.json'); + if (existsSync(themeJson)) { + themeJsonContents[path.basename(themeFolder)] = readFileSync(themeJson, { encoding: 'utf-8' }).replace( + /\r\n/g, + '\n' + ); + } + }); + } + + collectThemeJsonsInFrontend(themeJsonContents, settings.themeName); + + let webComponents: string[] = []; + if (webComponentTags) { + webComponents = webComponentTags.split(';'); + } + + const stats = { + packageJsonDependencies: projectPackageJson.dependencies, + npmModules: npmModuleAndVersion, + bundleImports: generatedImports, + frontendHashes: frontendFiles, + themeJsonContents: themeJsonContents, + entryScripts, + webComponents, + cvdlModules: cvdls, + packageJsonHash: projectPackageJson?.vaadin?.hash, + indexHtmlGenerated: rowsGenerated + }; + writeFileSync(statsFile, JSON.stringify(stats, null, 1)); } }; } @@ -213,7 +432,7 @@ function vaadinBundlesPlugin(): PluginOption { const disabledMessage = 'Vaadin component dependency bundles are disabled.'; - const modulesDirectory = path.resolve(__dirname, 'node_modules').replace(/\\/g, '/'); + const modulesDirectory = nodeModulesFolder.replace(/\\/g, '/'); let vaadinBundleJson: BundleJson; @@ -317,7 +536,8 @@ function vaadinBundlesPlugin(): PluginOption { exclude: [ // Vaadin bundle '@vaadin/bundles', - ...Object.keys(vaadinBundleJson.packages) + ...Object.keys(vaadinBundleJson.packages), + '@vaadin/vaadin-material-styles' ] } }, @@ -344,7 +564,7 @@ export { ${bindings.map(getExportBinding).join(', ')} };`; } function themePlugin(opts): PluginOption { - const fullThemeOptions = {...themeOptions, devMode: opts.devMode }; + const fullThemeOptions = { ...themeOptions, devMode: opts.devMode }; return { name: 'vaadin:theme', config() { @@ -353,7 +573,7 @@ function themePlugin(opts): PluginOption { configureServer(server) { function handleThemeFileCreateDelete(themeFile, stats) { if (themeFile.startsWith(themeFolder)) { - const changed = path.relative(themeFolder, themeFile) + const changed = path.relative(themeFolder, themeFile); console.debug('Theme file ' + (!!stats ? 'created' : 'deleted'), changed); processThemeResources(fullThemeOptions, console); } @@ -378,11 +598,13 @@ function themePlugin(opts): PluginOption { // force theme generation if generated theme sources does not yet exist // this may happen for example during Java hot reload when updating // @Theme annotation value - if (path.resolve(themeOptions.frontendGeneratedFolder, "theme.js") === importer && - !existsSync(path.resolve(themeOptions.frontendGeneratedFolder, id))) { - console.debug('Generate theme file ' + id + ' not existing. Processing theme resource'); - processThemeResources(fullThemeOptions, console); - return; + if ( + path.resolve(themeOptions.frontendGeneratedFolder, 'theme.js') === importer && + !existsSync(path.resolve(themeOptions.frontendGeneratedFolder, id)) + ) { + console.debug('Generate theme file ' + id + ' not existing. Processing theme resource'); + processThemeResources(fullThemeOptions, console); + return; } if (!id.startsWith(settings.themeFolder)) { return; @@ -398,7 +620,10 @@ function themePlugin(opts): PluginOption { async transform(raw, id, options) { // rewrite urls for the application theme css files const [bareId, query] = id.split('?'); - if (!bareId?.startsWith(themeFolder) || !bareId?.endsWith('.css')) { + if ( + (!bareId?.startsWith(themeFolder) && !bareId?.startsWith(themeOptions.themeResourceFolder)) || + !bareId?.endsWith('.css') + ) { return; } const [themeName] = bareId.substring(themeFolder.length + 1).split('/'); @@ -406,43 +631,6 @@ function themePlugin(opts): PluginOption { } }; } -function lenientLitImportPlugin(): PluginOption { - return { - name: 'vaadin:lenient-lit-import', - async transform(code, id) { - const decoratorImports = [ - /import (.*?) from (['"])(lit\/decorators)(['"])/, - /import (.*?) from (['"])(lit-element\/decorators)(['"])/ - ]; - const directiveImports = [ - /import (.*?) from (['"])(lit\/directives\/)([^\\.]*?)(['"])/, - /import (.*?) from (['"])(lit-html\/directives\/)([^\\.]*?)(['"])/ - ]; - - decoratorImports.forEach((decoratorImport) => { - let decoratorMatch; - while ((decoratorMatch = code.match(decoratorImport))) { - console.warn( - `Warning: the file ${id} imports from '${decoratorMatch[3]}' when it should import from '${decoratorMatch[3]}.js'` - ); - code = code.replace(decoratorImport, 'import $1 from $2$3.js$4'); - } - }); - - directiveImports.forEach((directiveImport) => { - let directiveMatch; - while ((directiveMatch = code.match(directiveImport))) { - console.warn( - `Warning: the file ${id} imports from '${directiveMatch[3]}${directiveMatch[4]}' when it should import from '${directiveMatch[3]}${directiveMatch[4]}.js'` - ); - code = code.replace(directiveImport, 'import $1 from $2$3$4.js$5'); - } - }); - - return code; - } - }; -} function runWatchDog(watchDogPort, watchDogHost) { const client = net.Socket(); @@ -460,26 +648,8 @@ function runWatchDog(watchDogPort, watchDogHost) { client.connect(watchDogPort, watchDogHost || 'localhost'); } -let spaMiddlewareForceRemoved = false; - -const allowedFrontendFolders = [ - frontendFolder, - path.resolve(generatedFlowImportsFolder), // Contains only generated-flow-imports - path.resolve(__dirname, 'node_modules') -]; +const allowedFrontendFolders = [frontendFolder, nodeModulesFolder]; -function setHmrPortToServerPort(): PluginOption { - return { - name: 'set-hmr-port-to-server-port', - configResolved(config) { - if (config.server.strictPort && config.server.hmr !== false) { - if (config.server.hmr === true) config.server.hmr = {}; - config.server.hmr = config.server.hmr || {}; - config.server.hmr.clientPort = config.server.port; - } - } - }; -} function showRecompileReason(): PluginOption { return { name: 'vaadin:why-you-compile', @@ -489,8 +659,35 @@ function showRecompileReason(): PluginOption { }; } +const DEV_MODE_START_REGEXP = /\/\*[\*!]\s+vaadin-dev-mode:start/; +const DEV_MODE_CODE_REGEXP = /\/\*[\*!]\s+vaadin-dev-mode:start([\s\S]*)vaadin-dev-mode:end\s+\*\*\//i; + +function preserveUsageStats() { + return { + name: 'vaadin:preserve-usage-stats', + + transform(src: string, id: string) { + if (id.includes('vaadin-usage-statistics')) { + if (src.includes('vaadin-dev-mode:start')) { + const newSrc = src.replace(DEV_MODE_START_REGEXP, '/*! vaadin-dev-mode:start'); + if (newSrc === src) { + console.error('Comment replacement failed to change anything'); + } else if (!newSrc.match(DEV_MODE_CODE_REGEXP)) { + console.error('New comment fails to match original regexp'); + } else { + return { code: newSrc }; + } + } + } + + return { code: src }; + } + }; +} + export const vaadinConfig: UserConfigFn = (env) => { const devMode = env.mode === 'development'; + const productionMode = !devMode && !devBundle if (devMode && process.env.watchDogPort) { // Open a connection with the Java dev-mode handler in order to finish @@ -501,6 +698,7 @@ export const vaadinConfig: UserConfigFn = (env) => { return { root: frontendFolder, base: '', + publicDir: false, resolve: { alias: { '@vaadin/flow-frontend': jarResourcesFolder, @@ -520,15 +718,26 @@ export const vaadinConfig: UserConfigFn = (env) => { } }, build: { - outDir: frontendBundleFolder, + outDir: buildOutputFolder, + emptyOutDir: devBundle, assetsDir: 'VAADIN/build', + target: ["esnext", "safari15"], rollupOptions: { input: { - indexhtml: path.resolve(frontendFolder, 'index.html'), + indexhtml: projectIndexHtml, - ...hasExportedWebComponents - ? { webcomponenthtml: path.resolve(frontendFolder, 'web-component.html') } - : {} + ...(hasExportedWebComponents ? { webcomponenthtml: path.resolve(frontendFolder, 'web-component.html') } : {}) + }, + onwarn: (warning: rollup.RollupWarning, defaultHandler: rollup.WarningHandler) => { + const ignoreEvalWarning = [ + 'generated/jar-resources/FlowClient.js', + 'generated/jar-resources/vaadin-spreadsheet/spreadsheet-export.js', + '@vaadin/charts/src/helpers.js' + ]; + if (warning.code === 'EVAL' && warning.id && !!ignoreEvalWarning.find((id) => warning.id.endsWith(id))) { + return; + } + defaultHandler(warning); } } }, @@ -548,44 +757,52 @@ export const vaadinConfig: UserConfigFn = (env) => { ] }, plugins: [ - !devMode && brotli(), + productionMode && brotli(), devMode && vaadinBundlesPlugin(), - devMode && setHmrPortToServerPort(), devMode && showRecompileReason(), settings.offlineEnabled && buildSWPlugin({ devMode }), !devMode && statsExtracterPlugin(), - themePlugin({devMode}), - lenientLitImportPlugin(), + devBundle && preserveUsageStats(), + themePlugin({ devMode }), postcssLit({ - include: ['**/*.css', '**/*.css\?*'], + include: ['**/*.css', /.*\/.*\.css\?.*/], exclude: [ `${themeFolder}/**/*.css`, - `${themeFolder}/**/*.css\?*`, + new RegExp(`${themeFolder}/.*/.*\\.css\\?.*`), `${themeResourceFolder}/**/*.css`, - `${themeResourceFolder}/**/*.css\?*`, - '**/*\?html-proxy*' + new RegExp(`${themeResourceFolder}/.*/.*\\.css\\?.*`), + new RegExp('.*/.*\\?html-proxy.*') ] }), + // The React plugin provides fast refresh and debug source info + reactPlugin({ + include: '**/*.tsx', + babel: { + // We need to use babel to provide the source information for it to be correct + // (otherwise Babel will slightly rewrite the source file and esbuild generate source info for the modified file) + presets: [['@babel/preset-react', { runtime: 'automatic', development: devMode }]], + // React writes the source location for where components are used, this writes for where they are defined + plugins: [ + devMode && addFunctionComponentSourceLocationBabel() + ].filter(Boolean) + } + }), { name: 'vaadin:force-remove-html-middleware', - transformIndexHtml: { - enforce: 'pre', - transform(_html, { server }) { - if (server && !spaMiddlewareForceRemoved) { - server.middlewares.stack = server.middlewares.stack.filter((mw) => { - const handleName = '' + mw.handle; - return !handleName.includes('viteHtmlFallbackMiddleware'); - }); - spaMiddlewareForceRemoved = true; - } - } - } + configureServer(server) { + return () => { + server.middlewares.stack = server.middlewares.stack.filter((mw) => { + const handleName = `${mw.handle}`; + return !handleName.includes('viteHtmlFallbackMiddleware'); + }); + }; + }, }, hasExportedWebComponents && { name: 'vaadin:inject-entrypoints-to-web-component-html', transformIndexHtml: { - enforce: 'pre', - transform(_html, { path, server }) { + order: 'pre', + handler(_html, { path, server }) { if (path !== '/web-component.html') { return; } @@ -596,15 +813,15 @@ export const vaadinConfig: UserConfigFn = (env) => { attrs: { type: 'module', src: `/generated/vaadin-web-component.ts` }, injectTo: 'head' } - ] + ]; } } }, { name: 'vaadin:inject-entrypoints-to-index-html', transformIndexHtml: { - enforce: 'pre', - transform(_html, { path, server }) { + order: 'pre', + handler(_html, { path, server }) { if (path !== '/index.html') { return; } @@ -629,7 +846,9 @@ export const vaadinConfig: UserConfigFn = (env) => { }, checker({ typescript: true - }) + }), + productionMode && visualizer({ brotliSize: true, filename: bundleSizeFile }) + ] }; }; @@ -637,3 +856,11 @@ export const vaadinConfig: UserConfigFn = (env) => { export const overrideVaadinConfig = (customConfig: UserConfigFn) => { return defineConfig((env) => mergeConfig(vaadinConfig(env), customConfig(env))); }; +function getVersion(module: string): string { + const packageJson = path.resolve(nodeModulesFolder, module, 'package.json'); + return JSON.parse(readFileSync(packageJson, { encoding: 'utf-8' })).version; +} +function getCvdlName(module: string): string { + const packageJson = path.resolve(nodeModulesFolder, module, 'package.json'); + return JSON.parse(readFileSync(packageJson, { encoding: 'utf-8' })).cvdlName; +} diff --git a/pom.xml b/pom.xml index f112c94..4c7d151 100644 --- a/pom.xml +++ b/pom.xml @@ -34,7 +34,7 @@ ${surefire-plugin.version} true - 24.0-SNAPSHOT + 24.4-SNAPSHOT 3.0.1.Final 1.16.0-alpha 1.16.0 diff --git a/runtime/src/main/java/com/vaadin/quarkus/QuarkusInstantiatorFactory.java b/runtime/src/main/java/com/vaadin/quarkus/QuarkusInstantiatorFactory.java index 15b70ac..890384c 100644 --- a/runtime/src/main/java/com/vaadin/quarkus/QuarkusInstantiatorFactory.java +++ b/runtime/src/main/java/com/vaadin/quarkus/QuarkusInstantiatorFactory.java @@ -36,8 +36,13 @@ public Instantiator createInstantitor(VaadinService vaadinService) { if (!getServiceClass().isAssignableFrom(vaadinService.getClass())) { return null; } - return new QuarkusInstantiator(new DefaultInstantiator(vaadinService), - beanManager); + DefaultInstantiator delegate = new DefaultInstantiator(vaadinService) { + @Override + protected ClassLoader getClassLoader() { + return Thread.currentThread().getContextClassLoader(); + } + }; + return new QuarkusInstantiator(delegate, beanManager); } /**