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'}); +};