diff --git a/.changeset/six-pets-confess.md b/.changeset/six-pets-confess.md
new file mode 100644
index 0000000000..28ae909ace
--- /dev/null
+++ b/.changeset/six-pets-confess.md
@@ -0,0 +1,5 @@
+---
+'@lit/reactive-element': minor
+---
+
+Adds optional boolean argument to `adoptStyles` allowing existing styling to be preserved.
diff --git a/packages/labs/ssr/README.md b/packages/labs/ssr/README.md
index 986dfeadcd..eaa01c9603 100644
--- a/packages/labs/ssr/README.md
+++ b/packages/labs/ssr/README.md
@@ -98,10 +98,8 @@ import './app-components.js';
const ssrResult = render(html`
-
-
+
-
@@ -112,7 +110,7 @@ const ssrResult = render(html`
// native declarative shadow roots)
import {
hasNativeDeclarativeShadowRoots,
- hydrateShadowRoots
+ hydrateShadowRoots,
} from './node_modules/@webcomponents/template-shadowroot/template-shadowroot.js';
if (!hasNativeDeclarativeShadowRoots()) {
hydrateShadowRoots(document.body);
@@ -121,7 +119,6 @@ const ssrResult = render(html`
// Load and hydrate components lazily
import('./app-components.js');
-
`);
diff --git a/packages/reactive-element/src/css-tag.ts b/packages/reactive-element/src/css-tag.ts
index 640d66f59b..4b03301153 100644
--- a/packages/reactive-element/src/css-tag.ts
+++ b/packages/reactive-element/src/css-tag.ts
@@ -101,10 +101,22 @@ type ConstructableCSSResult = CSSResult & {
): CSSResult;
};
+// Type guard for CSSResult
+const isCSSResult = (value: unknown): value is CSSResult =>
+ (value as CSSResult)['_$cssResult$'] === true;
+
+// Type guard for style element
+const isStyleEl = (
+ value: unknown
+): value is HTMLStyleElement | HTMLLinkElement => {
+ const {localName} = value as HTMLElement;
+ return localName === 'style' || localName === 'link';
+};
+
const textFromCSSResult = (value: CSSResultGroup | number) => {
// This property needs to remain unminified.
- if ((value as CSSResult)['_$cssResult$'] === true) {
- return (value as CSSResult).cssText;
+ if (isCSSResult(value)) {
+ return value.cssText;
} else if (typeof value === 'number') {
return value;
} else {
@@ -156,6 +168,48 @@ export const css = (
);
};
+// Markers used to determine where style elements have been inserted in the
+// shadowRoot so that they can be easily updated.
+const styleMarkersMap = new WeakMap();
+const getStyleMarkers = (renderRoot: ShadowRoot) => {
+ let markers = styleMarkersMap.get(renderRoot);
+ if (markers === undefined) {
+ styleMarkersMap.set(
+ renderRoot,
+ (markers = [
+ renderRoot.appendChild(document.createComment('')),
+ renderRoot.appendChild(document.createComment('')),
+ ])
+ );
+ }
+ return markers;
+};
+
+/**
+ * Clears any nodes between the given nodes. Used to remove style elements that
+ * have been inserted via `adoptStyles`. This allows ensures any previously
+ * applied styling is not re-applied.
+ */
+const removeNodesBetween = (start: Node, end: Node) => {
+ let n = start.nextSibling;
+ while (n && n !== end) {
+ const next = n.nextSibling;
+ n.remove();
+ n = next;
+ }
+};
+
+/**
+ * Applies the optional globally set `litNonce` to an element.
+ */
+const applyNonce = (el: HTMLElement) => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const nonce = (window as any)['litNonce'];
+ if (nonce !== undefined) {
+ el.setAttribute('nonce', nonce);
+ }
+};
+
/**
* Applies the given styles to a `shadowRoot`. When Shadow DOM is
* available but `adoptedStyleSheets` is not, styles are appended to the
@@ -164,27 +218,62 @@ export const css = (
* the shadowRoot should be placed *before* any shimmed adopted styles. This
* will match spec behavior that gives adopted sheets precedence over styles in
* shadowRoot.
+ *
+ * The given styles can be a CSSResult or CSSStyleSheet. If a CSSStyleSheet is
+ * supplied, it should be a constructed stylesheet.
+ *
+ * Optionally preserves any existing adopted styles.
*/
export const adoptStyles = (
renderRoot: ShadowRoot,
- styles: Array
+ styles: CSSResultOrNative[],
+ preserveExisting = false
) => {
- if (supportsAdoptingStyleSheets) {
- (renderRoot as ShadowRoot).adoptedStyleSheets = styles.map((s) =>
- s instanceof CSSStyleSheet ? s : s.styleSheet!
- );
- } else {
- styles.forEach((s) => {
+ // Get a set of sheets and elements to apply.
+ const elements: Array = [];
+ const sheets: CSSStyleSheet[] = styles
+ .map((s) => getSheetOrElementToApply(s))
+ .filter((s): s is CSSStyleSheet => !(isStyleEl(s) && elements.push(s)));
+ // By default, clear any existing styling.
+ if (supportsAdoptingStyleSheets && (sheets.length || !preserveExisting)) {
+ renderRoot.adoptedStyleSheets = [
+ ...(preserveExisting ? renderRoot.adoptedStyleSheets : []),
+ ...sheets,
+ ];
+ }
+ // Remove / Apply any style elements
+ if (!preserveExisting && styleMarkersMap.has(renderRoot)) {
+ removeNodesBetween(...getStyleMarkers(renderRoot));
+ }
+ if (elements.length) {
+ const [, end] = getStyleMarkers(renderRoot);
+ end.before(...elements);
+ }
+};
+
+/**
+ * Gets compatible style object (sheet or element) which can be applied to a
+ * shadowRoot.
+ */
+const getSheetOrElementToApply = (styling: CSSResultOrNative) => {
+ // Converts to a CSSResult if needed. This is needed when forcing polyfilled
+ // ShadyDOM/CSS on a browser that supports constructible stylesheets.
+ if (styling instanceof CSSStyleSheet) {
+ styling = getCompatibleStyle(styling);
+ }
+ // If it's a CSSResult, return the stylesheet or a style element
+ if (isCSSResult(styling)) {
+ if (styling.styleSheet !== undefined) {
+ return styling.styleSheet;
+ } else {
const style = document.createElement('style');
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const nonce = (global as any)['litNonce'];
- if (nonce !== undefined) {
- style.setAttribute('nonce', nonce);
- }
- style.textContent = (s as CSSResult).cssText;
- renderRoot.appendChild(style);
- });
+ style.textContent = styling.cssText;
+ applyNonce(style);
+ return style;
+ }
}
+ // Otherwise, it should be a constructed stylesheet
+ return styling;
};
const cssResultFromStyleSheet = (sheet: CSSStyleSheet) => {
@@ -195,6 +284,10 @@ const cssResultFromStyleSheet = (sheet: CSSStyleSheet) => {
return unsafeCSS(cssText);
};
+/**
+ * Given a CSSStylesheet or CSSResult, converts from CSSStyleSheet to CSSResult
+ * if the browser does not support `adoptedStyleSheets`.
+ */
export const getCompatibleStyle =
supportsAdoptingStyleSheets ||
(NODE_MODE && global.CSSStyleSheet === undefined)
diff --git a/packages/reactive-element/src/test/css-tag_test.ts b/packages/reactive-element/src/test/css-tag_test.ts
index 1ed1537ba8..909091096e 100644
--- a/packages/reactive-element/src/test/css-tag_test.ts
+++ b/packages/reactive-element/src/test/css-tag_test.ts
@@ -9,7 +9,9 @@ import {
CSSResult,
unsafeCSS,
supportsAdoptingStyleSheets,
+ adoptStyles,
} from '@lit/reactive-element/css-tag.js';
+import {html, getComputedStyleValue, createShadowRoot} from './test-helpers.js';
import {assert} from '@esm-bundle/chai';
suite('Styling', () => {
@@ -100,4 +102,124 @@ suite('Styling', () => {
assert.equal(bodyStyles.replace(/\s/g, ''), '.my-module{color:yellow;}');
});
});
+
+ suite('adopting styles', () => {
+ let container: HTMLElement;
+
+ setup(() => {
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ });
+
+ teardown(() => {
+ if (container && container.parentNode) {
+ container.remove();
+ }
+ });
+
+ test('adoptStyles sets styles in a shadowRoot', () => {
+ const host = document.createElement('host-el');
+ container.appendChild(host);
+ const root = createShadowRoot(host);
+ root.innerHTML = html`
+ `;
+ const div = root.querySelector('div')!;
+ const p = root.querySelector('p')!;
+ adoptStyles(root, [
+ css`
+ div {
+ border: 2px solid black;
+ }
+ `,
+ css`
+ p {
+ border: 4px solid black;
+ }
+ `,
+ ]);
+ assert.equal(getComputedStyleValue(div), '2px');
+ assert.equal(getComputedStyleValue(p), '4px');
+ });
+
+ test('adoptStyles can adopt CSSStyleSheet when supported', () => {
+ const host = document.createElement('host-el');
+ container.appendChild(host);
+ const root = createShadowRoot(host);
+ root.innerHTML = html``;
+ const div = root.querySelector('div')!;
+ let sheet: CSSStyleSheet | undefined;
+ try {
+ sheet = new CSSStyleSheet();
+ sheet.replaceSync(`div {
+ border: 12px solid black;
+ }`);
+ } catch (e) {
+ // unsupported
+ }
+ if (sheet !== undefined) {
+ adoptStyles(root, [sheet]);
+ assert.equal(getComputedStyleValue(div), '12px');
+ }
+ });
+
+ test('adoptStyles resets styles in a shadowRoot', () => {
+ const host = document.createElement('host-el');
+ container.appendChild(host);
+ const root = createShadowRoot(host);
+ root.innerHTML = html``;
+ const div = root.querySelector('div')!;
+ adoptStyles(root, [
+ css`
+ div {
+ border: 2px solid black;
+ }
+ `,
+ ]);
+ adoptStyles(root, []);
+ assert.equal(getComputedStyleValue(div), '0px');
+ });
+
+ test('adoptStyles can preserve and add to styles in a shadowRoot', () => {
+ const host = document.createElement('host-el');
+ container.appendChild(host);
+ const root = createShadowRoot(host);
+ root.innerHTML = html`
+ `;
+ const div = root.querySelector('div')!;
+ const p = root.querySelector('p')!;
+ adoptStyles(root, [
+ css`
+ div {
+ border: 2px solid black;
+ }
+ `,
+ ]);
+ adoptStyles(root, [], true);
+ assert.equal(getComputedStyleValue(div), '2px');
+ adoptStyles(
+ root,
+ [
+ css`
+ div {
+ border: 4px solid black;
+ }
+ `,
+ ],
+ true
+ );
+ adoptStyles(
+ root,
+ [
+ css`
+ p {
+ border: 6px solid black;
+ }
+ `,
+ ],
+ true
+ );
+ assert.equal(getComputedStyleValue(div), '4px');
+ assert.equal(getComputedStyleValue(p), '6px');
+ });
+ });
});
diff --git a/packages/reactive-element/src/test/test-helpers.ts b/packages/reactive-element/src/test/test-helpers.ts
index 4ef1aac871..11bfea6a67 100644
--- a/packages/reactive-element/src/test/test-helpers.ts
+++ b/packages/reactive-element/src/test/test-helpers.ts
@@ -12,10 +12,14 @@ export const generateElementName = () => `x-${count++}`;
export const nextFrame = () =>
new Promise((resolve) => requestAnimationFrame(resolve));
-export const getComputedStyleValue = (element: Element, property: string) =>
- window.ShadyCSS
+export const getComputedStyleValue = (
+ element: Element,
+ property = 'border-top-width'
+): string =>
+ (window.ShadyCSS
? window.ShadyCSS.getComputedStyleValue(element, property)
- : getComputedStyle(element).getPropertyValue(property);
+ : getComputedStyle(element).getPropertyValue(property)
+ ).trim();
export const stripExpressionComments = (html: string) =>
html.replace(/|/g, '');
@@ -62,3 +66,17 @@ export const html = (strings: TemplateStringsArray, ...values: unknown[]) => {
strings[0]
);
};
+
+export const createShadowRoot = (host: HTMLElement) => {
+ if (window.ShadyDOM && window.ShadyDOM.inUse) {
+ host = window.ShadyDOM.wrap(host) as HTMLElement;
+ if (window.ShadyCSS) {
+ window.ShadyCSS.prepareTemplateStyles(
+ document.createElement('template'),
+ host.localName
+ );
+ window.ShadyCSS.styleElement(host);
+ }
+ }
+ return host.attachShadow({mode: 'open'});
+};