-
Notifications
You must be signed in to change notification settings - Fork 879
/
shady-render.ts
312 lines (297 loc) · 14 KB
/
shady-render.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
/**
* @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
*/
/**
* Module to add shady DOM/shady CSS polyfill support to lit-html template
* rendering. See the [[render]] method for details.
*
* @module shady-render
* @preferred
*/
/**
* Do not remove this comment; it keeps typedoc from misplacing the module
* docs.
*/
import {removeNodes} from './dom.js';
import {insertNodeIntoTemplate, removeNodesFromTemplate} from './modify-template.js';
import {RenderOptions} from './render-options.js';
import {parts, render as litRender} from './render.js';
import {templateCaches} from './template-factory.js';
import {TemplateInstance} from './template-instance.js';
import {TemplateResult} from './template-result.js';
import {marker, Template} from './template.js';
export {html, svg, TemplateResult} from '../lit-html.js';
// Get a key to lookup in `templateCaches`.
const getTemplateCacheKey = (type: string, scopeName: string) =>
`${type}--${scopeName}`;
let compatibleShadyCSSVersion = true;
if (typeof window.ShadyCSS === 'undefined') {
compatibleShadyCSSVersion = false;
} else if (typeof window.ShadyCSS.prepareTemplateDom === 'undefined') {
console.warn(
`Incompatible ShadyCSS version detected. ` +
`Please update to at least @webcomponents/webcomponentsjs@2.0.2 and ` +
`@webcomponents/shadycss@1.3.1.`);
compatibleShadyCSSVersion = false;
}
/**
* Template factory which scopes template DOM using ShadyCSS.
* @param scopeName {string}
*/
const shadyTemplateFactory = (scopeName: string) =>
(result: TemplateResult) => {
const cacheKey = getTemplateCacheKey(result.type, scopeName);
let templateCache = templateCaches.get(cacheKey);
if (templateCache === undefined) {
templateCache = {
stringsArray: new WeakMap<TemplateStringsArray, Template>(),
keyString: new Map<string, Template>()
};
templateCaches.set(cacheKey, templateCache);
}
let template = templateCache.stringsArray.get(result.strings);
if (template !== undefined) {
return template;
}
const key = result.strings.join(marker);
template = templateCache.keyString.get(key);
if (template === undefined) {
const element = result.getTemplateElement();
if (compatibleShadyCSSVersion) {
window.ShadyCSS!.prepareTemplateDom(element, scopeName);
}
template = new Template(result, element);
templateCache.keyString.set(key, template);
}
templateCache.stringsArray.set(result.strings, template);
return template;
};
const TEMPLATE_TYPES = ['html', 'svg'];
/**
* Removes all style elements from Templates for the given scopeName.
*/
const removeStylesFromLitTemplates = (scopeName: string) => {
TEMPLATE_TYPES.forEach((type) => {
const templates = templateCaches.get(getTemplateCacheKey(type, scopeName));
if (templates !== undefined) {
templates.keyString.forEach((template) => {
const {element: {content}} = template;
// IE 11 doesn't support the iterable param Set constructor
const styles = new Set<Element>();
Array.from(content.querySelectorAll('style')).forEach((s: Element) => {
styles.add(s);
});
removeNodesFromTemplate(template, styles);
});
}
});
};
const shadyRenderSet = new Set<string>();
/**
* For the given scope name, ensures that ShadyCSS style scoping is performed.
* This is done just once per scope name so the fragment and template cannot
* be modified.
* (1) extracts styles from the rendered fragment and hands them to ShadyCSS
* to be scoped and appended to the document
* (2) removes style elements from all lit-html Templates for this scope name.
*
* Note, <style> elements can only be placed into templates for the
* initial rendering of the scope. If <style> elements are included in templates
* dynamically rendered to the scope (after the first scope render), they will
* not be scoped and the <style> will be left in the template and rendered
* output.
*/
const prepareTemplateStyles =
(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;
// If there are no styles, skip unnecessary work
if (length === 0) {
// Ensure prepareTemplateStyles is called to support adding
// styles via `prepareAdoptedCssText` since that requires that
// `prepareTemplateStyles` is called.
window.ShadyCSS!.prepareTemplateStyles(templateElement, scopeName);
return;
}
const condensedStyle = document.createElement('style');
// Collect styles into a single style. This helps us make sure ShadyCSS
// manipulations will not prevent us from being able to fix up template
// part indices.
// NOTE: collecting styles is inefficient for browsers but ShadyCSS
// currently does this anyway. When it does not, this should be changed.
for (let i = 0; i < length; i++) {
const style = styles[i];
style.parentNode!.removeChild(style);
condensedStyle.textContent! += style.textContent;
}
// Remove styles from nested templates in this scope.
removeStylesFromLitTemplates(scopeName);
// And then put the condensed style into the "root" template passed in as
// `template`.
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(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 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.
// There can be no style in the template in 2 cases (1) when Shady DOM
// is in use, ShadyCSS removes all styles, (2) when native Shadow DOM
// is in use ShadyCSS removes the style if it contains no content.
// NOTE, ShadyCSS creates its own style so we can safely add/remove
// `condensedStyle` here.
content.insertBefore(condensedStyle, content.firstChild);
const removes = new Set();
removes.add(condensedStyle);
removeNodesFromTemplate(template, removes);
}
};
export interface ShadyRenderOptions extends Partial<RenderOptions> {
scopeName: string;
}
/**
* Extension to the standard `render` method which supports rendering
* to ShadowRoots when the ShadyDOM (https://github.com/webcomponents/shadydom)
* and ShadyCSS (https://github.com/webcomponents/shadycss) polyfills are used
* or when the webcomponentsjs
* (https://github.com/webcomponents/webcomponentsjs) polyfill is used.
*
* Adds a `scopeName` option which is used to scope element DOM and stylesheets
* when native ShadowDOM is unavailable. The `scopeName` will be added to
* the class attribute of all rendered DOM. In addition, any style elements will
* be automatically re-written with this `scopeName` selector and moved out
* of the rendered DOM and into the document `<head>`.
*
* It is common to use this render method in conjunction with a custom element
* which renders a shadowRoot. When this is done, typically the element's
* `localName` should be used as the `scopeName`.
*
* In addition to DOM scoping, ShadyCSS also supports a basic shim for css
* custom properties (needed only on older browsers like IE11) and a shim for
* a deprecated feature called `@apply` that supports applying a set of css
* custom properties to a given location.
*
* Usage considerations:
*
* * Part values in `<style>` elements are only applied the first time a given
* `scopeName` renders. Subsequent changes to parts in style elements will have
* no effect. Because of this, parts in style elements should only be used for
* values that will never change, for example parts that set scope-wide theme
* values or parts which render shared style elements.
*
* * Note, due to a limitation of the ShadyDOM polyfill, rendering in a
* custom element's `constructor` is not supported. Instead rendering should
* either done asynchronously, for example at microtask timing (for example
* `Promise.resolve()`), or be deferred until the first time the element's
* `connectedCallback` runs.
*
* Usage considerations when using shimmed custom properties or `@apply`:
*
* * Whenever any dynamic changes are made which affect
* css custom properties, `ShadyCSS.styleElement(element)` must be called
* to update the element. There are two cases when this is needed:
* (1) the element is connected to a new parent, (2) a class is added to the
* element that causes it to match different custom properties.
* To address the first case when rendering a custom element, `styleElement`
* should be called in the element's `connectedCallback`.
*
* * Shimmed custom properties may only be defined either for an entire
* shadowRoot (for example, in a `:host` rule) or via a rule that directly
* matches an element with a shadowRoot. In other words, instead of flowing from
* parent to child as do native css custom properties, shimmed custom properties
* flow only from shadowRoots to nested shadowRoots.
*
* * When using `@apply` mixing css shorthand property names with
* non-shorthand names (for example `border` and `border-width`) is not
* supported.
*/
export const render =
(result: unknown,
container: Element|DocumentFragment|ShadowRoot,
options: ShadyRenderOptions) => {
if (!options || typeof options !== 'object' || !options.scopeName) {
throw new Error('The `scopeName` option is required.');
}
const scopeName = options.scopeName;
const hasRendered = parts.has(container);
const needsScoping = compatibleShadyCSSVersion &&
container.nodeType === 11 /* Node.DOCUMENT_FRAGMENT_NODE */ &&
!!(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
// fragment that is reused since nested renders can occur synchronously.
const renderContainer =
firstScopeRender ? document.createDocumentFragment() : container;
litRender(
result,
renderContainer,
{templateFactory: shadyTemplateFactory(scopeName), ...options} as
RenderOptions);
// When performing first scope render,
// (1) We've rendered into a fragment so that there's a chance to
// `prepareTemplateStyles` before sub-elements hit the DOM
// (which might cause them to render based on a common pattern of
// rendering in a custom element's `connectedCallback`);
// (2) Scope the template with ShadyCSS one time only for this scope.
// (3) Render the fragment into the container and make sure the
// container knows its `part` is the one we just rendered. This ensures
// DOM will be re-used on subsequent renders.
if (firstScopeRender) {
const part = parts.get(renderContainer)!;
parts.delete(renderContainer);
// 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. `prepareTemplateStyles` requires a
// template element (and the local wrapper requires a Template), so we
// create an empty one here to satisfy it.
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);
}
// After elements have hit the DOM, update styling if this is the
// 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 `ShadyCSS.styleElement`
// for dynamic changes.
if (!hasRendered && needsScoping) {
window.ShadyCSS!.styleElement((container as ShadowRoot).host);
}
};