Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[reactive-element] Adds preserveExisting option to adoptStyles #3060

Closed
wants to merge 11 commits into from
5 changes: 5 additions & 0 deletions .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.
7 changes: 2 additions & 5 deletions packages/labs/ssr/README.md
Expand Up @@ -98,10 +98,8 @@ import './app-components.js';

const ssrResult = render(html`
<html>
<head>
</head>
<head> </head>
<body>

<app-shell>
<app-page-one></app-page-one>
<app-page-two></app-page-two>
Expand All @@ -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);
Expand All @@ -121,7 +119,6 @@ const ssrResult = render(html`
// Load and hydrate components lazily
import('./app-components.js');
</script>

</body>
</html>
`);
Expand Down
127 changes: 110 additions & 17 deletions packages/reactive-element/src/css-tag.ts
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<ShadowRoot, [Comment, Comment]>();
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
Expand All @@ -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<CSSResultOrNative>
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<HTMLStyleElement | HTMLLinkElement> = [];
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) => {
Expand All @@ -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)
Expand Down
122 changes: 122 additions & 0 deletions packages/reactive-element/src/test/css-tag_test.ts
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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`<div></div>
<p></p>`;
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`<div></div>`;
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`<div></div>`;
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`<div></div>
<p></p>`;
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');
});
});
});
24 changes: 21 additions & 3 deletions packages/reactive-element/src/test/test-helpers.ts
Expand Up @@ -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(/<!--\?lit\$[0-9]+\$-->|<!---->/g, '');
Expand Down Expand Up @@ -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'});
};