@webtides/element-js 1.2.12 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,42 @@
1
1
  import { COMMENT_NODE } from '../util/DOMHelper.js';
2
2
 
3
+ const SHOW_COMMENT = 128; // NodeFilter.SHOW_COMMENT
4
+ const SLASH = 47; // '/'
5
+ const PART_MARKER_PREFIX = 'dom-part-';
6
+ const TEMPLATE_MARKER = 'template-part';
7
+
8
+ /**
9
+ * Walks the comment markers of one template instance in document order, starting at its `<!--template-part-->`
10
+ * comment, and calls back for every top level part marker with the marker node and the index of its part. Closing
11
+ * markers and everything inside nested templates (between their own template-part comments) are skipped.
12
+ *
13
+ * Works on a freshly cloned template fragment as well as on live, server rendered DOM: there the values sit between
14
+ * the markers, but they are not comments, so the walk sees the same sequence of markers in both cases.
15
+ *
16
+ * @param {Comment} startNode - the `<!--template-part-->` comment of the instance
17
+ * @param {(node: Comment, index: number) => void} [onPartMarker]
18
+ * @return {Comment} the matching `<!--/template-part-->` comment
19
+ */
20
+ export const walkPartMarkers = (startNode, onPartMarker) => {
21
+ const walker = globalThis.document.createTreeWalker(startNode.parentNode, SHOW_COMMENT);
22
+ walker.currentNode = startNode;
23
+ let depth = 1;
24
+ let node;
25
+ while ((node = walker.nextNode())) {
26
+ const data = node.data;
27
+ if (data === TEMPLATE_MARKER) {
28
+ depth++;
29
+ } else if (data === `/${TEMPLATE_MARKER}`) {
30
+ depth--;
31
+ if (depth === 0) return node;
32
+ } else if (depth === 1 && data.charCodeAt(0) !== SLASH && data.startsWith(PART_MARKER_PREFIX)) {
33
+ // 'dom-part-12?type=attribute&…' → 12, parseInt stops at the '?'
34
+ onPartMarker?.(node, parseInt(data.slice(PART_MARKER_PREFIX.length), 10));
35
+ }
36
+ }
37
+ throw new Error('PartMarkers: missing closing template-part marker');
38
+ };
39
+
3
40
  export class PartMarkers {
4
41
  /** @type {Comment} */
5
42
  startNode;
@@ -7,56 +44,77 @@ export class PartMarkers {
7
44
  /** @type {Comment} */
8
45
  endNode;
9
46
 
10
- /** @type {Node[]} */
47
+ /** @type {Node[]} all sibling nodes from the start to the end marker, both included */
11
48
  childNodes = [];
12
49
 
13
- /** @type {boolean} */
14
- serverSideRendered = false;
15
-
16
50
  /**
17
51
  * @param {Comment} start
18
52
  * @param {Comment} end
19
53
  * @param {Node[]} childNodes
20
- * @return {void}
21
54
  */
22
55
  constructor(start, end, childNodes) {
23
56
  this.startNode = start;
24
57
  this.endNode = end;
25
58
  this.childNodes = childNodes;
59
+ }
26
60
 
27
- // if not SSRed, childNodes will only ever have two comment nodes, the start and the end marker
28
- if (childNodes.length > 2) {
29
- this.serverSideRendered = true;
61
+ /**
62
+ * The start comments of the nested parts directly between the markers, in document order. Markers inside those
63
+ * nested parts are not included.
64
+ * @param {string} partType - the marker name to search for. Defaults to 'template-part'.
65
+ * @return {Comment[]}
66
+ */
67
+ findNestedStartNodes(partType = TEMPLATE_MARKER) {
68
+ const closing = `/${partType}`;
69
+ const startNodes = [];
70
+ let depth = 0;
71
+ for (const node of this.childNodes.slice(1, -1)) {
72
+ if (node.nodeType !== COMMENT_NODE) continue;
73
+ if (node.data === partType) {
74
+ if (depth === 0) startNodes.push(node);
75
+ depth++;
76
+ } else if (node.data === closing) {
77
+ depth--;
78
+ }
30
79
  }
80
+ return startNodes;
31
81
  }
32
82
 
33
83
  /**
34
- * @param {string} partType The type of the node to search for. Defaults to 'template-part'.
35
- * @return {Node|undefined} The first comment node matching the specified type, or undefined if no match is found.
84
+ * @param {string} partType - the marker name to search for. Defaults to 'template-part'.
85
+ * @return {Comment|undefined} the first nested start comment, or undefined if there is none
36
86
  */
37
- findNestedStartNode(partType = 'template-part') {
38
- return this.childNodes.filter((node) => node.nodeType === COMMENT_NODE).find((node) => node.data === partType);
87
+ findNestedStartNode(partType = TEMPLATE_MARKER) {
88
+ return this.findNestedStartNodes(partType)[0];
39
89
  }
40
90
 
41
91
  /**
42
- * Creates a new instance of PartMarkers from the given start node.
43
- * @param {Comment} startNode - The starting comment node that marks the beginning of the section.
44
- * @return {PartMarkers} A new instance of the PartMarkers class encapsulating the start and end nodes.
92
+ * Creates markers from a start comment by walking its siblings to the matching end comment. Nested markers with
93
+ * the same name, for example a nested template at the root of a template, are skipped.
94
+ * @param {Comment} startNode - the start comment that marks the beginning of the section
95
+ * @return {PartMarkers}
45
96
  */
46
97
  static createFromStartNode(startNode) {
47
- let endNode;
48
-
49
98
  const placeholder = startNode.data;
99
+ const closing = `/${placeholder}`;
50
100
  const childNodes = [startNode];
51
- let childNode = startNode.nextSibling;
52
- while (childNode && childNode.data !== `/${placeholder}`) {
53
- childNodes.push(childNode);
54
- childNode = childNode.nextSibling;
101
+ let depth = 1;
102
+ let node = startNode.nextSibling;
103
+ while (node) {
104
+ childNodes.push(node);
105
+ if (node.nodeType === COMMENT_NODE) {
106
+ if (node.data === placeholder) {
107
+ depth++;
108
+ } else if (node.data === closing) {
109
+ depth--;
110
+ if (depth === 0) break;
111
+ }
112
+ }
113
+ node = node.nextSibling;
55
114
  }
56
-
57
- endNode = childNode;
58
- childNodes.push(endNode);
59
-
60
- return new PartMarkers(startNode, endNode, childNodes);
115
+ if (!node) {
116
+ throw new Error(`PartMarkers: missing closing marker for "${placeholder}"`);
117
+ }
118
+ return new PartMarkers(startNode, node, childNodes);
61
119
  }
62
120
  }
@@ -1,38 +1,79 @@
1
1
  import { COMMENT_NODE, convertStringToTemplate } from '../util/DOMHelper.js';
2
2
  import { Part } from './Part.js';
3
- import { TemplateResult } from './TemplateResult.js';
4
3
  import { AttributePart } from './AttributePart.js';
5
4
  import { ChildNodePart, getNodesBetweenComments, replaceNodesBetweenComments } from './ChildNodePart.js';
6
5
  import { NodePart } from './NodePart.js';
7
6
  import { RawTextNodePart } from './RawTextNodePart.js';
8
- import { PartMarkers } from './PartMarkers.js';
9
-
10
- /** @type {WeakMap<TemplateStringsArray, Part[]>} */
11
- const partsCache = new WeakMap();
7
+ import { PartMarkers, walkPartMarkers } from './PartMarkers.js';
12
8
 
13
9
  /** @type {WeakMap<TemplateStringsArray, DocumentFragment>} */
14
10
  const fragmentsCache = new WeakMap();
15
11
 
12
+ /** @type {WeakMap<TemplateStringsArray, {descriptors: object[], markerData: string[], hasRootNodeParts: boolean}>} */
13
+ const partsCache = new WeakMap();
14
+
15
+ /**
16
+ * The parsed template for a `strings` array: the fragment to clone, the part descriptors in part order, the comment
17
+ * data of each part marker and whether a node part sits at the root of the template. All computed once per literal.
18
+ * @param {TemplateResult} templateResult
19
+ * @return {{fragment: DocumentFragment, descriptors: object[], markerData: string[], hasRootNodeParts: boolean}}
20
+ */
21
+ const parseTemplate = (templateResult) => {
22
+ let fragment = fragmentsCache.get(templateResult.strings);
23
+ if (!fragment) {
24
+ fragment = convertStringToTemplate(templateResult.templateString);
25
+ fragmentsCache.set(templateResult.strings, fragment);
26
+ }
27
+ let parts = partsCache.get(templateResult.strings);
28
+ if (!parts) {
29
+ const descriptors = templateResult.parseParts(fragment.childNodes);
30
+ const markerData = [];
31
+ let hasRootNodeParts = false;
32
+ walkPartMarkers(fragment.firstChild, (node, index) => {
33
+ markerData[index] = node.data;
34
+ if (descriptors[index].type === 'node' && node.parentNode === fragment) hasRootNodeParts = true;
35
+ });
36
+ parts = { descriptors, markerData, hasRootNodeParts };
37
+ partsCache.set(templateResult.strings, parts);
38
+ }
39
+ return { fragment, ...parts };
40
+ };
41
+
42
+ /**
43
+ * One rendered instance of a template. Binds the parts of the template to DOM nodes: either to a fresh clone of the
44
+ * template fragment, or, when hydrating, to the markers in server rendered DOM. Both bindings walk the same comment
45
+ * markers, see walkPartMarkers(). Construction only binds, values flow through update().
46
+ */
16
47
  export class TemplatePart extends Part {
17
- /** @type {PartMarkers|undefined} */
48
+ /** @type {PartMarkers} */
18
49
  markers;
19
50
 
20
- /** @type {Part[]} */
51
+ /** @type {Part[]} indexed like the values of the template */
21
52
  parts = [];
22
53
 
23
54
  /** @type {TemplateStringsArray} */
24
55
  strings = undefined;
25
56
 
26
- /** @type {Node[]} */
57
+ /**
58
+ * The top level nodes of this instance, including its template-part markers and the content of parts at the root
59
+ * of the template. Refreshed after every update while the nodes are attached, kept as is while they are detached
60
+ * so that a removed instance can be inserted again.
61
+ * @type {Node[]}
62
+ */
27
63
  childNodes = [];
28
- // TODO: there is childNodes here and also in this.markers now...
64
+
65
+ /** @type {boolean} true when the parts are bound to server rendered DOM */
66
+ hydrated = false;
67
+
68
+ /** @type {boolean} true when a node part at the root of the template can change the top level nodes */
69
+ hasRootNodeParts = false;
29
70
 
30
71
  /**
31
- * @param {Node|undefined} startNode - the start comment marker node
32
- * @param {TemplateResult} value
72
+ * @param {Comment|undefined} startNode - the `<!--template-part-->` comment of server rendered DOM to hydrate, or undefined to render client side
73
+ * @param {TemplateResult} templateResult
33
74
  */
34
- constructor(startNode, value) {
35
- if (startNode && startNode?.nodeType !== COMMENT_NODE) {
75
+ constructor(startNode, templateResult) {
76
+ if (startNode && startNode.nodeType !== COMMENT_NODE) {
36
77
  throw new Error('TemplatePart: startNode is not a comment node');
37
78
  }
38
79
 
@@ -40,86 +81,136 @@ export class TemplatePart extends Part {
40
81
 
41
82
  if (startNode) {
42
83
  startNode.__part = this; // add Part to comment node for debugging in the browser
43
- this.markers = PartMarkers.createFromStartNode(startNode);
44
- this.childNodes = this.markers.childNodes;
84
+ this.hydrated = this.bindLive(startNode, templateResult);
45
85
  }
46
-
47
- this.parseValue(value);
48
-
49
- if (!this.markers?.serverSideRendered) {
50
- // TODO: this is causing double diffing because of the this.parseValue() before, ChildNode parts will be constructed + processed
51
- // TODO: I think that maybe ChildNode Parts work, but Other Parts like Attributes are not set initially otherwise...
52
- this.updateParts(value.values);
86
+ if (!this.hydrated) {
87
+ this.bindClone(templateResult);
53
88
  }
54
-
55
- // TODO: this is somewhat strange that we are creating this twice...
56
- this.markers = PartMarkers.createFromStartNode(this.childNodes[0]);
57
89
  }
58
90
 
59
91
  /**
60
92
  * @param {TemplateResult} templateResult
93
+ * @param {boolean} [hydrate] - first update of server rendered DOM: nested parts bind to the server rendered
94
+ * markers instead of cloning, see ChildNodePart.parseValue(). Nodes that are in place already stay untouched.
61
95
  */
62
- update(templateResult) {
63
- const shouldUpdateDOM = this.strings !== templateResult.strings;
64
- if (shouldUpdateDOM) {
65
- this.parseValue(templateResult);
66
- // TODO: this is a strange way of replacing the childnodes...
67
- const endNode = this.childNodes[this.childNodes.length - 1];
68
- // INFO: this is similar to TemplateResult#237
69
- replaceNodesBetweenComments(this.markers?.endNode, getNodesBetweenComments(endNode));
96
+ update(templateResult, hydrate = false) {
97
+ if (this.strings !== templateResult.strings) {
98
+ // a different template: render it fresh and swap the content between the existing markers
99
+ const { startNode, endNode } = this.markers;
100
+ this.bindClone(templateResult);
101
+ const innerNodes = this.childNodes.slice(1, -1);
102
+ replaceNodesBetweenComments(endNode, innerNodes);
103
+ this.markers = new PartMarkers(startNode, endNode, [startNode, ...innerNodes, endNode]);
104
+ this.childNodes = this.markers.childNodes;
105
+ this.hydrated = false;
70
106
  }
71
- this.updateParts(templateResult.values);
107
+ // an instance that could not be bound to server DOM is a fresh clone, its parts clone as well
108
+ this.updateParts(templateResult.values, hydrate && this.hydrated);
109
+ if (this.hasRootNodeParts) this.refreshChildNodes();
72
110
  }
73
111
 
74
112
  /**
75
113
  * @param {any[]} values
114
+ * @param {boolean} [hydrate]
76
115
  */
77
- updateParts(values) {
116
+ updateParts(values, hydrate = false) {
78
117
  for (let index = 0; index < values.length; index++) {
79
- this.parts[index]?.update(values[index]);
118
+ this.parts[index]?.update(values[index], hydrate);
80
119
  }
81
120
  }
82
121
 
83
122
  /**
84
- * @param {TemplateResult} templateResult
123
+ * Re-reads the top level nodes between the markers. Parts at the root of the template insert and remove their
124
+ * content there, so the list is only known after an update. Detached nodes have no siblings to walk, the last
125
+ * known list is kept in that case.
85
126
  */
86
- parseValue(templateResult) {
87
- let fragment = fragmentsCache.get(templateResult.strings);
88
- if (!fragment) {
89
- fragment = convertStringToTemplate(templateResult.templateString);
90
- fragmentsCache.set(templateResult.strings, fragment);
127
+ refreshChildNodes() {
128
+ const { startNode, endNode } = this.markers;
129
+ if (startNode.parentNode) {
130
+ this.childNodes = [startNode, ...getNodesBetweenComments(endNode), endNode];
91
131
  }
92
- const importedFragment = globalThis.document?.importNode(fragment, true);
93
- this.childNodes = importedFragment.childNodes;
132
+ }
94
133
 
95
- let parts = partsCache.get(templateResult.strings);
96
- if (!parts) {
97
- parts = templateResult.parseParts(this.childNodes);
98
- partsCache.set(templateResult.strings, parts);
99
- }
134
+ /**
135
+ * Clones the template fragment and binds the parts to the markers in the clone.
136
+ * @param {TemplateResult} templateResult
137
+ */
138
+ bindClone(templateResult) {
139
+ const { fragment, descriptors, hasRootNodeParts } = parseTemplate(templateResult);
140
+ const clone = globalThis.document.importNode(fragment, true);
141
+ const startNode = clone.firstChild;
142
+ startNode.__part = this; // add Part to comment node for debugging in the browser
143
+ const parts = [];
144
+ const endNode = walkPartMarkers(startNode, (node, index) => {
145
+ parts[index] = this.createPart(descriptors[index], node, templateResult.values[index]);
146
+ });
147
+ this.parts = parts;
148
+ this.strings = templateResult.strings;
149
+ this.hasRootNodeParts = hasRootNodeParts;
150
+ this.childNodes = [...clone.childNodes];
151
+ this.markers = new PartMarkers(startNode, endNode, this.childNodes);
152
+ }
100
153
 
101
- this.parts = parts
102
- .map((part) => {
103
- // We currently need the path because the fragment will be cloned via importNode and therefore the node will be a different one
104
- part.node = part.path.reduceRight(({ childNodes }, i) => childNodes[i], this);
105
- return part;
106
- })
107
- .map((part, index) => {
108
- if (part.type === 'node') {
109
- return new ChildNodePart(part.node, templateResult.values[index]);
110
- }
111
- if (part.type === 'attribute') {
112
- return new AttributePart(part.node, part.name, part.initialValue);
113
- }
114
- if (part.type === 'raw-text-node') {
115
- return new RawTextNodePart(part.node, part.initialValue);
116
- }
117
- if (part.type === 'directive') {
118
- return new NodePart(part.node, templateResult.values[index]);
154
+ /**
155
+ * Binds the parts to the markers of server rendered DOM.
156
+ * @param {Comment} startNode - the live `<!--template-part-->` comment
157
+ * @param {TemplateResult} templateResult
158
+ * @return {boolean} false if the markers do not match the template, nothing is bound in that case
159
+ */
160
+ bindLive(startNode, templateResult) {
161
+ const { descriptors, markerData, hasRootNodeParts } = parseTemplate(templateResult);
162
+ const values = templateResult.values;
163
+ const parts = [];
164
+ let found = 0;
165
+ let endNode;
166
+ try {
167
+ endNode = walkPartMarkers(startNode, (node, index) => {
168
+ found++;
169
+ // the marker must be the one the template would render at this position: same type, name and
170
+ // static attribute content. Differences in the static markup around the markers are not detected.
171
+ if (descriptors[index] && node.data === markerData[index]) {
172
+ parts[index] = this.createPart(descriptors[index], node, values[index]);
119
173
  }
120
- throw `cannot map part: ${part}`;
121
174
  });
175
+ } catch (error) {
176
+ endNode = undefined;
177
+ }
178
+
179
+ const complete = found === descriptors.length && descriptors.every((_, index) => parts[index] !== undefined);
180
+ if (!endNode || !complete) {
181
+ console.warn(
182
+ 'element-js: the server rendered markers do not match the template, rendering client side instead.',
183
+ { template: templateResult.templateString, startNode },
184
+ );
185
+ return false;
186
+ }
187
+
188
+ this.parts = parts;
122
189
  this.strings = templateResult.strings;
123
- this.childNodes = [...this.childNodes];
190
+ this.hasRootNodeParts = hasRootNodeParts;
191
+ this.markers = PartMarkers.createFromStartNode(startNode);
192
+ this.childNodes = this.markers.childNodes;
193
+ return true;
194
+ }
195
+
196
+ /**
197
+ * @param {{type: string, name?: string, initialValue?: string}} descriptor
198
+ * @param {Comment} node - the marker comment
199
+ * @param {any} value - the first value, only needed for directives
200
+ * @return {Part}
201
+ */
202
+ createPart(descriptor, node, value) {
203
+ switch (descriptor.type) {
204
+ case 'node':
205
+ return new ChildNodePart(node);
206
+ case 'attribute':
207
+ return new AttributePart(node, descriptor.name, descriptor.initialValue);
208
+ case 'raw-text-node':
209
+ return new RawTextNodePart(node, descriptor.initialValue);
210
+ case 'directive':
211
+ return new NodePart(node, value);
212
+ default:
213
+ throw new Error(`TemplatePart: cannot create a part for type "${descriptor.type}"`);
214
+ }
124
215
  }
125
216
  }
@@ -1,6 +1,7 @@
1
- import { COMMENT_NODE, getNodePath, encodeHTML } from '../util/DOMHelper.js';
2
- import { encodeAttribute, isObjectLike } from '../util/AttributeParser.js';
1
+ import { COMMENT_NODE, encodeHTML } from '../util/DOMHelper.js';
2
+ import { camelToDash, encodeAttribute, isObjectLike } from '../util/AttributeParser.js';
3
3
  import { TemplatePart } from './TemplatePart.js';
4
+ import { walkPartMarkers } from './PartMarkers.js';
4
5
 
5
6
  const voidElements = /^(?:area|base|br|col|embed|hr|img|input|link|meta|source|track|wbr)$/i;
6
7
  const rawTextElements = /<(script|style|textarea|title)([^>]*)>(.*?)<\/\1>/i;
@@ -203,6 +204,39 @@ const ssrUpdatesCache = new WeakMap();
203
204
  /** @type {Map<Element, TemplatePart>} */
204
205
  const templatePartsCache = new WeakMap();
205
206
 
207
+ /**
208
+ * @param {Node[] | NodeList} childNodes
209
+ * @return {Comment|undefined} the first `<!--template-part-->` comment among the nodes
210
+ */
211
+ const findTemplatePartStartNode = (childNodes) =>
212
+ Array.from(childNodes).find((node) => node.nodeType === COMMENT_NODE && node.data === 'template-part');
213
+
214
+ /**
215
+ * Parses the descriptor of a part from the data of its comment marker.
216
+ * For html`<div id="${'foo'}" class="${'bar'}">${'baz'}</div>` the markers are:
217
+ * <!--dom-part-0?type=attribute&name=id&initialValue=\x03--><!--dom-part-1?type=attribute&name=class&initialValue=\x03--><div><!--dom-part-2-->
218
+ * @param {string} data
219
+ * @return {{type: string, name?: string, initialValue?: string}}
220
+ */
221
+ const parsePartDescriptor = (data) => {
222
+ const query = data.indexOf('?');
223
+ if (query === -1) {
224
+ return { type: 'node' };
225
+ }
226
+ const searchParams = new URLSearchParams(data.slice(query + 1));
227
+ const type = searchParams.get('type');
228
+ if (type === 'attribute') {
229
+ return { type, name: searchParams.get('name'), initialValue: searchParams.get('initialValue') };
230
+ }
231
+ if (type === 'raw-text-node') {
232
+ return { type, initialValue: searchParams.get('initialValue') };
233
+ }
234
+ if (type === 'directive') {
235
+ return { type };
236
+ }
237
+ throw new Error(`TemplateResult: unknown part type "${type}" in marker "${data}"`);
238
+ };
239
+
206
240
  export class TemplateResult {
207
241
  /**
208
242
  * @param {TemplateStringsArray} strings
@@ -214,30 +248,26 @@ export class TemplateResult {
214
248
  }
215
249
 
216
250
  /**
251
+ * Renders into a DOM node. A node that already holds a rendered instance of this template is updated, a node with
252
+ * server rendered markup (a `<!--template-part-->` marker) is hydrated, anything else is replaced by a fresh render.
217
253
  * @param {Element} domNode
218
254
  */
219
255
  renderInto(domNode) {
220
- const startNode = Array.from(domNode.childNodes)
221
- .filter((node) => node.nodeType === COMMENT_NODE)
222
- .find((node) => node.data === 'template-part');
223
-
224
- let serverSideRendered = startNode !== undefined;
256
+ const startNode = findTemplatePartStartNode(domNode.childNodes);
225
257
 
226
258
  let templatePart = templatePartsCache.get(domNode);
259
+ if (templatePart && startNode) {
260
+ templatePart.update(this);
261
+ return;
262
+ }
227
263
 
228
- if (!templatePart || !startNode) {
229
- if (!startNode) {
230
- domNode.replaceChildren();
231
- }
232
-
233
- templatePart = new TemplatePart(startNode, this);
234
- templatePartsCache.set(domNode, templatePart);
264
+ templatePart = new TemplatePart(startNode, this);
265
+ templatePartsCache.set(domNode, templatePart);
235
266
 
236
- if (!serverSideRendered) {
237
- domNode.replaceChildren(...templatePart.childNodes);
238
- }
239
- } else {
240
- templatePart.update(this);
267
+ const hydrated = templatePart.hydrated;
268
+ templatePart.update(this, hydrated);
269
+ if (!hydrated) {
270
+ domNode.replaceChildren(...templatePart.childNodes);
241
271
  }
242
272
  }
243
273
 
@@ -281,7 +311,9 @@ export class TemplateResult {
281
311
  }
282
312
 
283
313
  if (name.startsWith('.')) {
284
- const propertyName = name.replace('.', '');
314
+ // a property cannot be set in a string, the value is written as the attribute that
315
+ // BaseElement maps back to the property on upgrade: camelCase becomes dash-case
316
+ const attributeName = camelToDash(name.replace('.', ''));
285
317
  return [
286
318
  {
287
319
  type: 'attribute',
@@ -291,13 +323,13 @@ export class TemplateResult {
291
323
  return '';
292
324
  }
293
325
  // in all other cases, just escape it in quotes
294
- return attribute(propertyName, value, quotes);
326
+ return attribute(attributeName, value, quotes);
295
327
  },
296
328
  },
297
329
  ];
298
330
  }
299
331
 
300
- if (name.startsWith('@') || name.startsWith('on')) {
332
+ if (name.startsWith('@')) {
301
333
  return [
302
334
  {
303
335
  type: 'attribute',
@@ -333,7 +365,13 @@ export class TemplateResult {
333
365
  // Note: this will coarse the values into strings, but it's probably ok since there can only be multiple values in string attributes?!
334
366
  const parsedValue = initialValue.replace(/\x03/g, () => values[replaceIndex++]);
335
367
  values = [];
336
- return attribute(searchParams.get('name'), parsedValue, quotes);
368
+ // an attribute whose sole value is null or undefined is left out, the DOM renderer
369
+ // removes it as well
370
+ if (initialValue === '\x03' && value == null) return '';
371
+ // a listener on an "old school" event attribute (onevent=${...}) has nothing to do on
372
+ // the server side, any other value of an attribute that starts with "on" is written
373
+ if (name.startsWith('on') && typeof value === 'function') return '';
374
+ return attribute(name, parsedValue, quotes);
337
375
  },
338
376
  });
339
377
  return attributeUpdates;
@@ -369,72 +407,22 @@ export class TemplateResult {
369
407
  }
370
408
 
371
409
  /**
372
- * @param {Node[]} childNodes
373
- * @return {object[]}
410
+ * Parses the part descriptors of this template from the comment markers of its rendered or cloned nodes.
411
+ * The descriptors are indexed like the values of the template.
412
+ * @param {Node[] | NodeList} childNodes - the top level nodes of a template instance, including the template-part markers
413
+ * @return {{type: string, name?: string, initialValue?: string}[]}
374
414
  */
375
415
  parseParts(childNodes) {
376
- // we always create a template fragment so that we can start at the root for traversing the node path
377
- const range = globalThis.document?.createRange();
378
- range.setStartBefore(childNodes[0]);
379
- range.setEndAfter(childNodes[childNodes.length - 1]);
380
- const template = range.cloneContents();
381
-
382
- const treeWalker = globalThis.document?.createTreeWalker(template, 128);
383
- let node = treeWalker.nextNode(); //to skip the root template-part
416
+ const startNode = findTemplatePartStartNode(childNodes);
384
417
  const parts = [];
385
-
386
- let nestedLevel = 0;
387
- // search for parts through numbered comment nodes with placeholders
388
- while ((node = treeWalker.nextNode())) {
389
- if (/^template-part$/.test(node.data)) {
390
- nestedLevel++;
391
- continue;
392
- }
393
- if (/^\/template-part$/.test(node.data)) {
394
- if (nestedLevel > 0) {
395
- nestedLevel--;
396
- }
397
- continue;
398
- }
399
- if (nestedLevel > 0) {
400
- continue;
401
- }
402
- if (/^dom-part-\d+$/.test(node.data)) {
403
- parts.push({ type: 'node', path: getNodePath(node) });
404
- continue;
405
- }
406
- if (/^dom-part-\d+\?type=attribute&(.*?)$/.test(node.data)) {
407
- // For html`<div id="${'foo'}" class="${'bar'}"></div>` we will get:
408
- // <!--dom-part-0?type=attribute&name=id&initialValue=\x03--><!--dom-part-1?type=attribute&name=class&initialValue=\x03-->
409
- const [_, paramsString] = node.data.split('?');
410
- const searchParams = new URLSearchParams(paramsString);
411
- parts.push({
412
- type: 'attribute',
413
- path: getNodePath(node),
414
- name: searchParams.get('name'),
415
- initialValue: searchParams.get('initialValue'),
416
- });
417
- continue;
418
- }
419
- if (/^dom-part-\d+\?type=raw-text-node&(.*?)$/.test(node.data)) {
420
- // For html`<textarea>${'foo'} bar</textarea>` we will get:
421
- // <!--dom-part-0?type=raw-text-node&initialValue=\x03 bar-->
422
- const [_, paramsString] = node.data.split('?');
423
- const searchParams = new URLSearchParams(paramsString);
424
- parts.push({
425
- type: 'raw-text-node',
426
- path: getNodePath(node),
427
- initialValue: searchParams.get('initialValue'),
428
- });
429
- continue;
430
- }
431
- if (/^dom-part-\d+\?type=directive$/.test(node.data)) {
432
- parts.push({ type: 'directive', path: getNodePath(node) });
433
- }
418
+ if (startNode) {
419
+ walkPartMarkers(startNode, (node, index) => {
420
+ parts[index] = parsePartDescriptor(node.data);
421
+ });
434
422
  }
435
423
 
436
424
  // We couldn't correctly parse parts from the template
437
- if (parts.length !== this.strings.length - 1) {
425
+ if (parts.length !== this.strings.length - 1 || parts.includes(undefined)) {
438
426
  throw {
439
427
  name: 'ParseTemplateError',
440
428
  message:
@@ -443,7 +431,6 @@ export class TemplateResult {
443
431
  strings: this.strings,
444
432
  templateString: this.templateString,
445
433
  childNodes,
446
- template,
447
434
  parts,
448
435
  }),
449
436
  };