diff --git a/package.json b/package.json index d92d15a211..9296d113bd 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "devDependencies": { "@types/chai": "^4.1.0", "@types/mocha": "^5.2.0", - "@webcomponents/shadycss": "^1.5.2", + "@webcomponents/shadycss": "^1.8.0", "@webcomponents/webcomponentsjs": "^2.0.4", "chai": "^4.1.2", "clang-format": "^1.2.4", diff --git a/src/env.d.ts b/src/env.d.ts index da0c99a6bc..9e693d9221 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -5,6 +5,10 @@ interface ShadyCSS { prepareTemplateDom(template: Element, elementName: string): void; prepareTemplateStyles( template: Element, elementName: string, typeExtension?: string): void; + ScopingShim: undefined|{ + prepareAdoptedCssText( + cssTextArray: Array, elementName: string): void; + }; } interface ShadyDOM { diff --git a/src/lib/parts.ts b/src/lib/parts.ts index 09874acaeb..fbb4ad6d9c 100644 --- a/src/lib/parts.ts +++ b/src/lib/parts.ts @@ -245,15 +245,18 @@ export class NodePart implements Part { private __commitText(value: unknown): void { const node = this.startNode.nextSibling!; value = value == null ? '' : value; + // If `value` isn't already a string, we explicitly convert it here in case + // it can't be implicitly converted - i.e. it's a symbol. + const valueAsString: string = + typeof value === 'string' ? value : String(value); if (node === this.endNode.previousSibling && node.nodeType === 3 /* Node.TEXT_NODE */) { // If we only have a single text node between the markers, we can just // set its value, rather than replacing it. // TODO(justinfagnani): Can we just check if this.value is primitive? - (node as Text).data = value as string; + (node as Text).data = valueAsString; } else { - this.__commitNode(document.createTextNode( - typeof value === 'string' ? value : String(value))); + this.__commitNode(document.createTextNode(valueAsString)); } this.value = value; } diff --git a/src/lib/render.ts b/src/lib/render.ts index 03d23d277f..109a8918e0 100644 --- a/src/lib/render.ts +++ b/src/lib/render.ts @@ -20,18 +20,17 @@ import {removeNodes} from './dom.js'; import {NodePart} from './parts.js'; import {RenderOptions} from './render-options.js'; import {templateFactory} from './template-factory.js'; -import {TemplateResult} from './template-result.js'; export const parts = new WeakMap(); /** - * Renders a template to a container. + * Renders a template result or other value to a container. * * To update a container with new values, reevaluate the template literal and * call `render` with the new result. * - * @param result a TemplateResult created by evaluating a template tag like - * `html` or `svg`. + * @param result Any value renderable by NodePart - typically a TemplateResult + * created by evaluating a template tag like `html` or `svg`. * @param container A DOM parent to render to. The entire contents are either * replaced, or efficiently updated if the same result type was previous * rendered there. @@ -40,7 +39,7 @@ export const parts = new WeakMap(); * container, as those changes will not effect previously rendered DOM. */ export const render = - (result: TemplateResult, + (result: unknown, container: Element|DocumentFragment, options?: Partial) => { let part = parts.get(container); diff --git a/src/lib/shady-render.ts b/src/lib/shady-render.ts index 03e29a7100..ddde69a24d 100644 --- a/src/lib/shady-render.ts +++ b/src/lib/shady-render.ts @@ -125,8 +125,13 @@ const shadyRenderSet = new Set(); * output. */ const prepareTemplateStyles = - (renderedDOM: DocumentFragment, template: Template, scopeName: string) => { + (scopeName: string, renderedDOM: DocumentFragment, template?: Template) => { shadyRenderSet.add(scopeName); + // If `renderedDOM` is stamped from a Template, then we need to edit that + // Template's underlying template element. Otherwise, we create one here + // to give to ShadyCSS, which still requires one while scoping. + const templateElement = + !!template ? template.element : document.createElement('template'); // Move styles out of rendered DOM and store. const styles = renderedDOM.querySelectorAll('style'); const {length} = styles; @@ -135,7 +140,14 @@ const prepareTemplateStyles = // Ensure prepareTemplateStyles is called to support adding // styles via `prepareAdoptedCssText` since that requires that // `prepareTemplateStyles` is called. - window.ShadyCSS!.prepareTemplateStyles(template.element, scopeName); + // + // ShadyCSS will only update styles containing @apply in the template + // given to `prepareTemplateStyles`. If no lit Template was given, + // ShadyCSS will not be able to update uses of @apply in any relevant + // template. However, this is not a problem because we only create the + // template for the purpose of supporting `prepareAdoptedCssText`, + // which doesn't support @apply at all. + window.ShadyCSS!.prepareTemplateStyles(templateElement, scopeName); return; } const condensedStyle = document.createElement('style'); @@ -153,18 +165,22 @@ const prepareTemplateStyles = removeStylesFromLitTemplates(scopeName); // And then put the condensed style into the "root" template passed in as // `template`. - const content = template.element.content; - insertNodeIntoTemplate(template, condensedStyle, content.firstChild); + const content = templateElement.content; + if (!!template) { + insertNodeIntoTemplate(template, condensedStyle, content.firstChild); + } else { + content.insertBefore(condensedStyle, content.firstChild); + } // Note, it's important that ShadyCSS gets the template that `lit-html` // will actually render so that it can update the style inside when // needed (e.g. @apply native Shadow DOM case). - window.ShadyCSS!.prepareTemplateStyles(template.element, scopeName); + window.ShadyCSS!.prepareTemplateStyles(templateElement, scopeName); const style = content.querySelector('style'); if (window.ShadyCSS!.nativeShadow && style !== null) { // When in native Shadow DOM, ensure the style created by ShadyCSS is // included in initially rendered output (`renderedDOM`). renderedDOM.insertBefore(style.cloneNode(true), renderedDOM.firstChild); - } else { + } else if (!!template) { // When no style is left in the template, parts will be broken as a // result. To fix this, we put back the style node ShadyCSS removed // and then tell lit to remove that node from the template. @@ -241,7 +257,7 @@ export interface ShadyRenderOptions extends Partial { * supported. */ export const render = - (result: TemplateResult, + (result: unknown, container: Element|DocumentFragment|ShadowRoot, options: ShadyRenderOptions) => { if (!options || typeof options !== 'object' || !options.scopeName) { @@ -251,7 +267,7 @@ export const render = const hasRendered = parts.has(container); const needsScoping = compatibleShadyCSSVersion && container.nodeType === 11 /* Node.DOCUMENT_FRAGMENT_NODE */ && - !!(container as ShadowRoot).host && result instanceof TemplateResult; + !!(container as ShadowRoot).host; // Handle first render to a scope specially... const firstScopeRender = needsScoping && !shadyRenderSet.has(scopeName); // On first scope render, render into a fragment; this cannot be a single @@ -275,12 +291,16 @@ export const render = if (firstScopeRender) { const part = parts.get(renderContainer)!; parts.delete(renderContainer); - if (part.value instanceof TemplateInstance) { - prepareTemplateStyles( - renderContainer as DocumentFragment, - part.value.template, - scopeName); - } + // ShadyCSS might have style sheets (e.g. from `prepareAdoptedCssText`) + // that should apply to `renderContainer` even if the rendered value is + // not a TemplateInstance. However, it will only insert scoped styles + // into the document if `prepareTemplateStyles` has already been called + // for the given scope name. + const template = part.value instanceof TemplateInstance ? + part.value.template : + undefined; + prepareTemplateStyles( + scopeName, renderContainer as DocumentFragment, template); removeNodes(container, container.firstChild); container.appendChild(renderContainer); parts.set(container, part); @@ -289,7 +309,7 @@ export const render = // initial render to this container. // This is needed whenever dynamic changes are made so it would be // safest to do every render; however, this would regress performance - // so we leave it up to the user to call `ShadyCSSS.styleElement` + // so we leave it up to the user to call `ShadyCSS.styleElement` // for dynamic changes. if (!hasRendered && needsScoping) { window.ShadyCSS!.styleElement((container as ShadowRoot).host); diff --git a/src/test/lib/parts_test.ts b/src/test/lib/parts_test.ts index 5b2f4d91ff..e3d887975e 100644 --- a/src/test/lib/parts_test.ts +++ b/src/test/lib/parts_test.ts @@ -124,6 +124,53 @@ suite('Parts', () => { assert.equal(stripExpressionMarkers(container.innerHTML), ''); }); + test('accepts a symbol', () => { + const sym = Symbol(); + part.setValue(sym); + part.commit(); + assert.equal(stripExpressionMarkers(container.innerHTML), String(sym)); + }); + + test('accepts a symbol with a description', () => { + const sym = Symbol('description!'); + part.setValue(sym); + part.commit(); + assert.equal(stripExpressionMarkers(container.innerHTML), String(sym)); + }); + + test('accepts a symbol on subsequent renders', () => { + const sym1 = Symbol(); + part.setValue(sym1); + part.commit(); + assert.equal(stripExpressionMarkers(container.innerHTML), String(sym1)); + + // If the previously rendered value caused a single text node to be + // created, then subsequent renders will try to update the existing text + // node by setting `.data`. If the new value is a symbol and it isn't + // explicitly converted with `String`, then this would throw. + const sym2 = Symbol('description!'); + part.setValue(sym2); + part.commit(); + assert.equal(stripExpressionMarkers(container.innerHTML), String(sym2)); + }); + + test('accepts an object', () => { + part.setValue({}); + part.commit(); + assert.equal( + stripExpressionMarkers(container.innerHTML), '[object Object]'); + }); + + test('accepts an object with a `toString` method', () => { + part.setValue({ + toString() { + return 'toString!'; + } + }); + part.commit(); + assert.equal(stripExpressionMarkers(container.innerHTML), 'toString!'); + }); + test('accepts a function', () => { const f = () => { throw new Error(); diff --git a/src/test/lib/render_test.ts b/src/test/lib/render_test.ts index 4044661a92..a2e2904bb8 100644 --- a/src/test/lib/render_test.ts +++ b/src/test/lib/render_test.ts @@ -1409,4 +1409,52 @@ suite('render()', () => { assert(container.innerHTML, '
'); }); }); + + // `render` directly passes the given value to `NodePart#setValue`, so these + // tests are really just a sanity check that they are accepted by `render`. + // Tests about rendering behavior for specific values should generally be + // grouped with those of `NodePart#setValue` and `#commit`. + suite('accepts types other than TemplateResult', () => { + test('accepts undefined', () => { + render(undefined, container); + assert.equal(stripExpressionMarkers(container.innerHTML), ''); + }); + + test('accepts a string', () => { + render('test', container); + assert.equal(stripExpressionMarkers(container.innerHTML), 'test'); + }); + + test('accepts an object', () => { + render({}, container); + assert.equal( + stripExpressionMarkers(container.innerHTML), '[object Object]'); + }); + + test('accepts an object with `toString`', () => { + render( + { + toString() { + return 'toString!'; + } + }, + container); + assert.equal(stripExpressionMarkers(container.innerHTML), 'toString!'); + }); + + test('accepts an symbol', () => { + const sym = Symbol('description!'); + render(sym, container); + assert.equal(stripExpressionMarkers(container.innerHTML), String(sym)); + }); + + test('accepts a node', () => { + const div = document.createElement('div'); + div.appendChild(document.createTextNode('text in the div')); + render(div, container); + assert.equal( + stripExpressionMarkers(container.innerHTML), + '
text in the div
'); + }); + }); }); diff --git a/src/test/lib/shady-render-apply_test.ts b/src/test/lib/shady-render-apply_test.ts index 8f66ca7bee..fcec3dfdf0 100644 --- a/src/test/lib/shady-render-apply_test.ts +++ b/src/test/lib/shady-render-apply_test.ts @@ -49,6 +49,31 @@ suite('shady-render @apply', () => { document.body.removeChild(container); }); + test('styles with mixins that are not in a TemplateInstance', function() { + const container = document.createElement('scope-6'); + document.body.appendChild(container); + const style = document.createElement('style'); + style.innerHTML = ` + :host { + --batch: { + border: 3px solid orange; + padding: 4px; + }; + } + div { + @apply --batch; + } + `; + const result = [style, htmlWithApply`
Testing...
`]; + renderShadowRoot(result, container); + const div = (container.shadowRoot!).querySelector('div'); + const computedStyle = getComputedStyle(div!); + assert.equal( + computedStyle.getPropertyValue('border-top-width').trim(), '3px'); + assert.equal(computedStyle.getPropertyValue('padding-top').trim(), '4px'); + document.body.removeChild(container); + }); + test( 'styles with css custom properties using @apply render in different contexts', async () => { diff --git a/src/test/lib/shady-render-scoping-shim_test.ts b/src/test/lib/shady-render-scoping-shim_test.ts new file mode 100644 index 0000000000..c180f6e808 --- /dev/null +++ b/src/test/lib/shady-render-scoping-shim_test.ts @@ -0,0 +1,90 @@ +/** + * @license + * Copyright (c) 2017 The Polymer Project Authors. All rights reserved. + * This code may only be used under the BSD style license found at + * http://polymer.github.io/LICENSE.txt + * The complete set of authors may be found at + * http://polymer.github.io/AUTHORS.txt + * The complete set of contributors may be found at + * http://polymer.github.io/CONTRIBUTORS.txt + * Code distributed by Google as part of the polymer project is also + * subject to an additional IP rights grant found at + * http://polymer.github.io/PATENTS.txt + */ + +import {html} from '../../lib/shady-render.js'; +import {renderShadowRoot} from '../test-utils/shadow-root.js'; + +const assert = chai.assert; + +suite('shady-render scoping shim', () => { + setup(function() { + if (typeof window.ShadyDOM === 'undefined' || !window.ShadyDOM.inUse || + typeof window.ShadyCSS === 'undefined' || + window.ShadyCSS.nativeShadow || + window.ShadyCSS.ScopingShim === undefined) { + this.skip(); + return; + } + }); + + let testName; + + testName = 'scoped styles are applied for non-TemplateResult values'; + test(testName, function() { + const container = document.createElement('scope-1'); + window.ShadyCSS!.ScopingShim!.prepareAdoptedCssText( + [':host { border-top: 2px solid black; }'], 'scope-1'); + document.body.appendChild(container); + renderShadowRoot(undefined, container); + assert.equal( + getComputedStyle(container).getPropertyValue('border-top-width').trim(), + '2px'); + document.body.removeChild(container); + }); + + testName = 'adopted CSS remains when rendering a TemplateResult after an ' + + 'initial non-TemplateResult'; + test(testName, function() { + const container = document.createElement('scope-2'); + window.ShadyCSS!.ScopingShim!.prepareAdoptedCssText( + [':host { border-top: 2px solid black; } button { font-size: 7px; } '], + 'scope-2'); + document.body.appendChild(container); + renderShadowRoot(undefined, container); + assert.equal( + getComputedStyle(container).getPropertyValue('border-top-width').trim(), + '2px'); + renderShadowRoot(html``, container); + assert.equal( + getComputedStyle(container).getPropertyValue('border-top-width').trim(), + '2px'); + assert.equal( + getComputedStyle(container.shadowRoot!.querySelector('button')!) + .getPropertyValue('font-size') + .trim(), + '7px'); + document.body.removeChild(container); + }); + + testName = 'Styles inserted in the initial render through NodeParts are ' + + 'scoped.'; + test(testName, function() { + const style = document.createElement('style'); + style.innerHTML = + ':host { border-top: 2px solid black; } button { font-size: 7px; }'; + const container = document.createElement('scope-3'); + document.body.appendChild(container); + renderShadowRoot( + html`${style}`, container); + assert.equal( + getComputedStyle(container).getPropertyValue('border-top-width').trim(), + '2px'); + assert.equal( + getComputedStyle(container.shadowRoot!.querySelector('button')!) + .getPropertyValue('font-size') + .trim(), + '7px'); + document.body.removeChild(container); + }); +}); diff --git a/src/test/test-utils/shadow-root.ts b/src/test/test-utils/shadow-root.ts index 35fe86d805..4aebd137ef 100644 --- a/src/test/test-utils/shadow-root.ts +++ b/src/test/test-utils/shadow-root.ts @@ -12,12 +12,11 @@ * http://polymer.github.io/PATENTS.txt */ import {render} from '../../lib/shady-render.js'; -import {TemplateResult} from '../../lit-html.js'; /** * A helper for creating a shadowRoot on an element. */ -export const renderShadowRoot = (result: TemplateResult, element: Element) => { +export const renderShadowRoot = (result: unknown, element: Element) => { if (!element.shadowRoot) { element.attachShadow({mode: 'open'}); } diff --git a/test/index.html b/test/index.html index 1569d2e7d2..692006ae60 100644 --- a/test/index.html +++ b/test/index.html @@ -32,6 +32,9 @@ 'shady-apply.html', 'shady-apply.html?shadydom=true', 'shady-apply.html?shadydom=true&shimcssproperties=true', + 'shady-scoping-shim.html', + 'shady-scoping-shim.html?shadydom=true', + 'shady-scoping-shim.html?shadydom=true&shimcssproperties=true', 'no-template.html', 'shady_no-wc.html', ]); diff --git a/test/shady-scoping-shim.html b/test/shady-scoping-shim.html new file mode 100644 index 0000000000..07d9ca0713 --- /dev/null +++ b/test/shady-scoping-shim.html @@ -0,0 +1,21 @@ + + + + + + + + + + + + + +