Skip to content

Commit

Permalink
fix: apply CssImport to exported webcomponent (#19740) (CP: 23.5) (#1…
Browse files Browse the repository at this point in the history
…9752)

CssImport annotation with just a value attribute can be injected automatically to shadow root of all exported web components (embedded applications) using Constructable StyleSheets. WebComponentExporter should have a theme to make automation work properly in theme-generator.js. Theme property "autoInjectGlobalCssImports": true in theme.json enable auto injection. Disabled by default.

Fixes: #19700
  • Loading branch information
tltv authored Aug 7, 2024
1 parent 12979ee commit 4e199fe
Show file tree
Hide file tree
Showing 10 changed files with 203 additions and 9 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.ListIterator;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
Expand Down Expand Up @@ -63,6 +65,7 @@ abstract class AbstractUpdateImports implements Runnable {
+ " tpl.innerHTML = block;\n"
+ " document.head[before ? 'insertBefore' : 'appendChild'](tpl.content, document.head.firstChild);\n"
+ "};";
private static final String IMPORT_WC_INJECT = "import { injectGlobalWebcomponentCss } from 'Frontend/generated/jar-resources/theme-util.js';\n";

private static final String CSS_IMPORT = "import $cssFromFile_%d from '%s';%n" //
+ "const $css_%1$d = typeof $cssFromFile_%1$d === 'string' ? unsafeCSS($cssFromFile_%1$d) : $cssFromFile_%1$d;";
Expand All @@ -77,6 +80,9 @@ abstract class AbstractUpdateImports implements Runnable {
+ " let ae=document.activeElement;"
+ " while(ae&&ae.shadowRoot) ae = ae.shadowRoot.activeElement;"
+ " return !ae || ae.blur() || ae.focus() || true;" + "}";
private static final Pattern INJECT_CSS_PATTERN = Pattern.compile(
"^\\s*addCssBlock\\(`<style>\\$\\{([^}]+)\\}</style>`\\);.*$");
private static final String INJECT_WC_CSS = "injectGlobalWebcomponentCss(%s);";

private static final String IMPORT_TEMPLATE = "import '%s';";

Expand Down Expand Up @@ -136,13 +142,27 @@ public void run() {
List<String> filterWebComponentImports(List<String> lines) {
if (lines != null) {
// Exclude Lumo global imports for exported web-component
return lines.stream()
.filter(line -> !line.contains("/lumo-includes.ts"))
.collect(Collectors.toList());
List<String> copy = new ArrayList<>(lines);
copy.add(0, IMPORT_WC_INJECT);
copy.removeIf(line -> line.contains("/lumo-includes.ts"));
// Add global CSS imports with a per-webcomponent registration
final ListIterator<String> li = copy.listIterator();
while (li.hasNext()) {
adaptCssInjectForWebComponent(li, li.next());
}
return copy;
}
return lines;
}

private void adaptCssInjectForWebComponent(ListIterator<String> iterator,
String line) {
Matcher matcher = INJECT_CSS_PATTERN.matcher(line);
if (matcher.matches()) {
iterator.add(String.format(INJECT_WC_CSS, matcher.group(1)));
}
}

/**
* Get all ES6 modules needed for run the application. Modules that are
* theme dependencies are guaranteed to precede other modules in the result.
Expand Down
59 changes: 59 additions & 0 deletions flow-server/src/main/resources/META-INF/frontend/theme-util.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
const webcomponentGlobalCss = {
css: [],
importers: []
};

export const injectGlobalWebcomponentCss = (css) => {
webcomponentGlobalCss.css.push(css);
webcomponentGlobalCss.importers.forEach(registrar => {
registrar(css);
});
};

export const webcomponentGlobalCssInjector = (registrar) => {
const registeredCss = [];
const wrapper = (css) => {
const hash = getHash(css);
if (!registeredCss.includes(hash)) {
registeredCss.push(hash);
registrar(css);
}
};
webcomponentGlobalCss.importers.push(wrapper);
webcomponentGlobalCss.css.forEach(wrapper);
};

/**
* Calculate a 32 bit FNV-1a hash
* Found here: https://gist.github.com/vaiorabbit/5657561
* Ref.: http://isthe.com/chongo/tech/comp/fnv/
*
* @param {string} str the input value
* @returns {string} 32 bit (as 8 byte hex string)
*/
function hashFnv32a(str) {
/*jshint bitwise:false */
let i,
l,
hval = 0x811c9dc5;

for (i = 0, l = str.length; i < l; i++) {
hval ^= str.charCodeAt(i);
hval += (hval << 1) + (hval << 4) + (hval << 7) + (hval << 8) + (hval << 24);
}

// Convert to 8 digit hex string
return ('0000000' + (hval >>> 0).toString(16)).substr(-8);
}

/**
* Calculate a 64 bit hash for the given input.
* Double hash is used to significantly lower the collision probability.
*
* @param {string} input value to get hash for
* @returns {string} 64 bit (as 16 byte hex string)
*/
function getHash(input) {
let h1 = hashFnv32a(input); // returns 32 bit (as 8 byte hex string)
return h1 + hashFnv32a(h1 + input);
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,16 +38,16 @@ const createLinkReferences = (css, target) => {
// [4] matches the url in '@import "..."'
// [5] matches media query on @import statement
const importMatcher = /(?:@media\\s(.+?))?(?:\\s{)?\\@import\\s*(?:url\\(\\s*['"]?(.+?)['"]?\\s*\\)|(["'])((?:\\\\.|[^\\\\])*?)\\3)([^;]*);(?:})?/g
// Only cleanup if comment exist
if(/\\/\\*(.|[\\r\\n])*?\\*\\//gm.exec(css) != null) {
// clean up comments
css = stripCssComments(css);
}
var match;
var styleCss = css;
// For each external url import add a link reference
while((match = importMatcher.exec(css)) !== null) {
styleCss = styleCss.replace(match[0], "");
Expand Down Expand Up @@ -101,6 +101,7 @@ export const injectGlobalCss = (css, target, first) => {
function generateThemeFile(themeFolder, themeName, themeProperties, productionMode) {
const styles = path.resolve(themeFolder, stylesCssFile);
const document = path.resolve(themeFolder, documentCssFile);
const autoInjectGlobalCssImports = themeProperties.autoInjectGlobalCssImports ?? false;
const componentsFiles = glob.sync('*.css', {
cwd: path.resolve(themeFolder, themeComponentsFolder),
nodir: true
Expand All @@ -119,6 +120,7 @@ function generateThemeFile(themeFolder, themeName, themeProperties, productionMo

themeFile += createLinkReferences;
themeFile += injectGlobalCssMethod;
themeFile += `import { webcomponentGlobalCssInjector } from 'Frontend/generated/jar-resources/theme-util.js';\n`;

const imports = [];
const globalCssCode = [];
Expand Down Expand Up @@ -255,16 +257,23 @@ function hashFnv32a(str) {
*/
function getHash(input) {
let h1 = hashFnv32a(input); // returns 32 bit (as 8 byte hex string)
return h1 + hashFnv32a(h1 + input);
return h1 + hashFnv32a(h1 + input);
}
`;

// Don't format as the generated file formatting will get wonky!
// If targets check that we only register the style parts once, checks exist for global css and component css
const themeFileApply = `export const applyTheme = (target) => {
if (target !== document) {
${autoInjectGlobalCssImports ? `
webcomponentGlobalCssInjector((css) => {
injectGlobalCss(css, target);
});
` : ''}
}
${parentTheme}
${globalCssCode.join('')}
if (!document['${componentCssFlag}']) {
${componentCssCode.join('')}
document['${componentCssFlag}'] = true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,43 @@ public void jsModuleOnRouterLayout_shouldBe_addedAfterLumoStyles() {
"Frontend/common-js-file.js");
}

@Test
public void generate_embeddedImports_addAlsoGlobalStyles()
throws IOException {
Class<?>[] testClasses = { SimpleCssImport.class, UI.class };
ClassFinder classFinder = getClassFinder(testClasses);
updater = new UpdateImports(classFinder, getScanner(classFinder),
tmpRoot, new File(tmpRoot, TOKEN_FILE), true, featureFlags);
updater.run();

Pattern injectGlobalCssPattern = Pattern.compile(
"^\\s*addCssBlock\\(`<style>\\$\\{([^}]+)\\}</style>`\\);.*$");
Predicate<String> globalCssImporter = injectGlobalCssPattern
.asPredicate();

List<String> globalCss = updater.resultingLines.stream()
.filter(globalCssImporter).map(line -> {
Matcher matcher = injectGlobalCssPattern.matcher(line);
matcher.find();
return matcher.group(1);
}).collect(Collectors.toList());

assertTrue("Import for web-components should also inject global CSS",
updater.webcomponentImports.stream()
.anyMatch(globalCssImporter));

assertTrue(
"Should contain function to import global CSS into embedded component",
updater.webcomponentImports.stream().anyMatch(line -> line
.contains("import { injectGlobalWebcomponentCss }")));
globalCss.forEach(css -> assertTrue(
"Should register global CSS " + css + " for webcomponent",
updater.webcomponentImports.stream()
.anyMatch(line -> line.contains(
"injectGlobalWebcomponentCss(" + css + ");"))));

}

@Test
public void jsModulesOrderIsPreservedAnsAfterJsModules() {
updater.run();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
DIV.cssimport {
color: gold
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"autoInjectGlobalCssImports": true,
"documentCss": ["@fortawesome/fontawesome-free/css/all.css"],
"assets": {
"@fortawesome/fontawesome-free": {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*
* 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.webcomponent;

import com.vaadin.flow.component.Tag;
import com.vaadin.flow.component.Text;
import com.vaadin.flow.component.dependency.CssImport;
import com.vaadin.flow.component.html.Div;

@Tag("css-import-component")
@CssImport("./css-import-component.css")
public class CssImportComponent extends Div {

public CssImportComponent(String id) {
setId(id);
Div div = new Div(new Text(
"Global CssImport styles should be applied inside embedded web component, this should not be black"));
div.setClassName("cssimport");
add(div);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
import com.vaadin.flow.component.dependency.NpmPackage;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.Span;

import com.vaadin.flow.uitest.ui.dependencies.TestVersion;

@NpmPackage(value = "@fortawesome/fontawesome-free", version = TestVersion.FONTAWESOME)
Expand All @@ -20,6 +19,7 @@ public class ThemedComponent extends Div {
public static final String TEST_TEXT_ID = "test-text";

public static final String MY_COMPONENT_ID = "field";
public static final String CSS_IMPORT_COMPONENT_ID = "embedded-cssimport";
public static final String EMBEDDED_ID = "embedded";

public static final String HAND_ID = "sparkle-hand";
Expand All @@ -38,5 +38,6 @@ public ThemedComponent() {

add(new Div());
add(new MyComponent().withId(MY_COMPONENT_ID));
add(new CssImportComponent(CSS_IMPORT_COMPONENT_ID));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ <h1>Lumo styles should not be applied</h1>
<div class="internal" id="internal">
Internal should not apply, this should be black
</div>
<div class="cssimport" id="cssimport">
CssImport styles should apply, this should not be black
</div>
<div class="global" id="global">
Document styles should apply, this should be blue
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,14 @@ private void validateEmbeddedComponent(TestBenchElement themedComponent,

Assert.assertEquals("Color should have been applied",
"rgba(0, 128, 0, 1)", handElement.getCssValue("color"));

// Ensure @CssImport styles are applied
final WebElement cssImportElement = embeddedComponent
.$("css-import-component").first().$(DivElement.class).first();
Assert.assertEquals(
"Color fom CSSImport annotation should have been applied",
"rgba(255, 215, 0, 1)", cssImportElement.getCssValue("color"));

}

@Test
Expand Down Expand Up @@ -240,4 +248,22 @@ public void lumoImports_doNotLeakEmbeddingPage() {
"rgba(0, 0, 0, 1)", element.getCssValue("color"));

}

@Test
public void cssImportAnnotation_applyToEmbeddingPage() {
open();
checkLogsForErrors();

// Ensure embedded components are loaded before testing embedding page
validateEmbeddedComponent($("themed-component").id("first"), "first");
validateEmbeddedComponent($("themed-component").id("second"), "second");

final DivElement element = $(DivElement.class)
.attribute("id", "cssimport").waitForFirst();
Assert.assertEquals(
"CssImport styles (colors) should have been applied to elements in embedding page",
"rgba(255, 215, 0, 1)", element.getCssValue("color"));

}

}

0 comments on commit 4e199fe

Please sign in to comment.