@webtides/element-js 1.2.12 → 1.3.0

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.
@@ -158,27 +158,36 @@ const attributeString = spreadAttributes(attrs);
158
158
 
159
159
  ##### Writing custom directives
160
160
 
161
- Directives in `element-js` are just functions. They accept parameters and return values that manipulate or produce templates or strings.
161
+ The built-in directives above are plain functions that return a value for an attribute or a child position. A directive that works on the element itself sits inside the opening tag, between the attributes. It is a class that extends `Directive`, wrapped with `defineDirective()`:
162
162
 
163
- To write a custom directive:
164
-
165
- Define a Function: This function will describe what your directive does. The function should return either a template, string, or another function based on its parameters.
163
+ - `update(...values)` runs in the browser on every render, `this.node` is the element the directive sits on.
164
+ - `stringify(...values)` runs when the template is rendered to a string, for example on the server, and returns what is written into the opening tag.
166
165
 
167
166
  ```javascript
168
- const visibility = (isVisible) => {
169
- return isVisible ? '' : 'style="display: none;"';
170
- };
167
+ import { Directive, defineDirective } from '@webtides/element-js';
168
+
169
+ class HighlightDirective extends Directive {
170
+ update(active) {
171
+ this.node.toggleAttribute('data-highlight', active);
172
+ }
173
+
174
+ stringify(active) {
175
+ return active ? 'data-highlight' : '';
176
+ }
177
+ }
178
+
179
+ const highlight = defineDirective(HighlightDirective);
171
180
  ```
172
181
 
173
- Use your custom directive in your templates.
182
+ `defineDirective()` returns the function to call in templates, its arguments are passed on to `update()` and `stringify()`:
174
183
 
175
184
  ```javascript
176
185
  function template() {
177
- return html` <div ${visibility(false)}>This div will be hidden.</div> `;
186
+ return html` <div ${highlight(this.active)}>Highlighted while active.</div> `;
178
187
  }
179
188
  ```
180
189
 
181
- Remember, the power of directives is in their simplicity. They allow you to encapsulate logic in a function and reuse that logic across multiple templates.
190
+ A plain function at this position throws, the renderer only accepts a directive created with `defineDirective()`. The `about` tab of `examples/showcase/` uses this directive.
182
191
 
183
192
  #### Slots
184
193
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webtides/element-js",
3
- "version": "1.2.12",
3
+ "version": "1.3.0",
4
4
  "module": "index.js",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -36,12 +36,12 @@
36
36
  "element-js",
37
37
  "shadow-dom"
38
38
  ],
39
- "dependencies": {},
40
39
  "devDependencies": {
41
40
  "@babel/eslint-parser": "^7.23.3",
42
41
  "@open-wc/testing": "^4.0.0",
43
42
  "@web/dev-server": "^0.4.6",
44
43
  "@web/test-runner": "^0.19.0",
44
+ "@web/test-runner-commands": "^0.9.0",
45
45
  "babel-eslint": "^10.1.0",
46
46
  "eslint": "8.55.0",
47
47
  "eslint-config-prettier": "^9.1.0",
@@ -60,6 +60,9 @@
60
60
  "scripts": {
61
61
  "lint": "eslint index.js 'src/**/*.js'",
62
62
  "test": "web-test-runner test/**/*.test.{html,js} --node-resolve",
63
+ "bench": "node benchmark/run.mjs",
64
+ "bench:compare": "node benchmark/run.mjs --base main",
65
+ "bench:ui": "web-dev-server --root-dir . --open /benchmark/",
63
66
  "test:coverage": "web-test-runner test/**/*.test.{html,js} --node-resolve --coverage",
64
67
  "test:watch": "web-test-runner test/**/*.test.{html,js} --node-resolve --watch",
65
68
  "start": "web-dev-server --config web-dev-server.config.mjs"
@@ -55,10 +55,12 @@ class TemplateElement extends StyledElement {
55
55
  * This will attach a shadow DOM if the element is supposed to render in shadow DOM.
56
56
  */
57
57
  renderTemplate() {
58
- const firstRender = this._options.shadowRender && !this.shadowRoot;
59
- if (firstRender) {
58
+ if (this._options.shadowRender && !this.shadowRoot) {
60
59
  this.attachShadow({ mode: 'open' });
61
- // init external stylesheets
60
+ }
61
+ // the first render adopts the style sheets, also into a declarative shadow root that the parser attached
62
+ if (this._options.shadowRender && !this._stylesAdopted) {
63
+ this._stylesAdopted = true;
62
64
  this.initStyleAdoption();
63
65
  }
64
66
 
@@ -1,16 +1,30 @@
1
1
  import { Part } from './Part.js';
2
2
 
3
+ // marks the first update: it compares with the attribute in the DOM once, so a fresh clone, which carries the
4
+ // placeholder, gets written and server rendered markup that fits already is left alone
5
+ const NOT_SET = Symbol('not set');
6
+
3
7
  /**
4
8
  * @param {Element} node
5
9
  * @param {String} name
6
- * @param {Boolean} oldValue
7
10
  * @return {(function(*): void)|*}
8
11
  */
9
- const processBooleanAttribute = (node, name, oldValue) => {
12
+ const processBooleanAttribute = (node, name) => {
13
+ let oldValue = NOT_SET;
10
14
  return (newValue) => {
11
15
  const value = !!newValue?.valueOf() && newValue !== 'false';
16
+ if (oldValue === NOT_SET && node.getAttribute(name) === (value ? '' : null)) {
17
+ oldValue = value;
18
+ return;
19
+ }
12
20
  if (oldValue !== value) {
13
- node.toggleAttribute(name, (oldValue = !!value));
21
+ oldValue = value;
22
+ // not toggleAttribute(): server rendered markup may carry `name="false"`, true has to become `name=""`
23
+ if (value) {
24
+ node.setAttribute(name, '');
25
+ } else {
26
+ node.removeAttribute(name);
27
+ }
14
28
  }
15
29
  };
16
30
  };
@@ -49,9 +63,13 @@ const processEventAttribute = (node, name) => {
49
63
  * @return {(function(*): void)|*}
50
64
  */
51
65
  const processAttribute = (node, name) => {
52
- let oldValue;
66
+ let oldValue = NOT_SET;
53
67
  return (newValue) => {
54
68
  const value = newValue?.valueOf();
69
+ if (oldValue === NOT_SET && node.getAttribute(name) === (value == null ? null : String(value))) {
70
+ oldValue = value;
71
+ return;
72
+ }
55
73
  if (oldValue !== value) {
56
74
  oldValue = value;
57
75
  if (value == null) {
@@ -74,7 +92,7 @@ const attributePartsCache = new WeakMap();
74
92
  export const processAttributePart = (node, name) => {
75
93
  // boolean attribute: ?boolean=${...}
76
94
  if (name.startsWith('?')) {
77
- return processBooleanAttribute(node, name.slice(1), false);
95
+ return processBooleanAttribute(node, name.slice(1));
78
96
  }
79
97
 
80
98
  // property attribute: .property=${...}
@@ -82,11 +100,23 @@ export const processAttributePart = (node, name) => {
82
100
  return processPropertyAttribute(node, name.slice(1));
83
101
  }
84
102
 
85
- // event attribute: @event=${...} || "old school" event attribute: onevent=${...}
86
- if (name.startsWith('@') || name.startsWith('on')) {
103
+ // event attribute: @event=${...}
104
+ if (name.startsWith('@')) {
87
105
  return processEventAttribute(node, name);
88
106
  }
89
107
 
108
+ // "old school" event attribute: onevent=${...}. A function is the listener, any other value is written as an
109
+ // attribute: `on="${state}"` or `once="${flag}"` are attributes of the element that happen to start with "on"
110
+ if (name.startsWith('on')) {
111
+ const listener = processEventAttribute(node, name);
112
+ const attribute = processAttribute(node, name);
113
+ return (value) => {
114
+ const isListener = typeof value === 'function';
115
+ listener(isListener ? value : undefined);
116
+ attribute(isListener ? undefined : value);
117
+ };
118
+ }
119
+
90
120
  // normal "string" attribute: attribute=${...}
91
121
  return processAttribute(node, name);
92
122
  };
@@ -1,4 +1,4 @@
1
- import { COMMENT_NODE, ELEMENT_NODE } from '../util/DOMHelper.js';
1
+ import { COMMENT_NODE, TEXT_NODE } from '../util/DOMHelper.js';
2
2
  import { Part } from './Part.js';
3
3
  import { TemplateResult } from './TemplateResult.js';
4
4
  import { TemplatePart } from './TemplatePart.js';
@@ -69,42 +69,54 @@ export const getNodesBetweenComments = (commentNode, includeComments = false) =>
69
69
  };
70
70
 
71
71
  /**
72
- * @param {Node[]} newChildNodes
73
- * @param {Comment} anchorNode
72
+ * A node that is not in the DOM yet takes over an equal node that is in place: a fresh text node leaves the text node
73
+ * that is there alone. The nodes of a template clone still sit in their fragment and never take over, they replace
74
+ * what the server rendered in their place.
75
+ * @param {Node} node
76
+ * @param {Node} newChildNode
77
+ * @return {boolean}
74
78
  */
75
- const diffChildNodes = function (newChildNodes, anchorNode) {
76
- const parentNode = anchorNode.parentNode;
77
- const oldChildNodes = getNodesBetweenComments(anchorNode);
78
-
79
- const length = Math.max(oldChildNodes.length, newChildNodes.length);
80
- // Diff each node in the child node lists
81
- for (let index = 0; index < length; index++) {
82
- let oldChildNode = oldChildNodes[index];
83
- let newChildNode = newChildNodes[index];
79
+ const takesOver = (node, newChildNode) => !newChildNode.parentNode && node.isEqualNode(newChildNode);
84
80
 
85
- if (newChildNode && !newChildNode.nodeType) {
81
+ /**
82
+ * Brings the nodes between the markers in line with newChildNodes, in that order. Walks the live DOM with a cursor:
83
+ * a node that is at the cursor already stays, stale nodes at the cursor are removed, anything else is inserted in
84
+ * front of the cursor, which moves a node that is in the DOM further back, for example a hydrated template behind a
85
+ * server rendered text. Nodes that are in place are never touched, so focus and element lifecycles survive.
86
+ * @param {Node[]} newChildNodes
87
+ * @param {Comment} startNode - the start marker
88
+ * @param {Comment} endNode - the end marker
89
+ */
90
+ const diffChildNodes = (newChildNodes, startNode, endNode) => {
91
+ const parentNode = endNode.parentNode;
92
+ // the set of new nodes tells a stale node from one that is needed further on. It is only built when the nodes
93
+ // deviate from what is there, a list whose nodes are all in place never pays for it
94
+ let keep;
95
+ let current = startNode.nextSibling;
96
+ for (let index = 0; index < newChildNodes.length; index++) {
97
+ const newChildNode = newChildNodes[index];
98
+ if (!newChildNode?.nodeType) {
86
99
  throw new Error('ChildNodePart: newChildNode is not a node');
87
100
  }
88
-
89
- // If the DOM node doesn't exist, append/copy the new node
90
- if (oldChildNode === undefined) {
91
- parentNode.insertBefore(newChildNode, anchorNode);
92
- continue;
93
- }
94
-
95
- // If the new node doesn't exist, remove the node in the DOM
96
- if (newChildNode === undefined) {
97
- parentNode.removeChild(oldChildNode);
98
- continue;
101
+ if (current !== newChildNode && current !== endNode) {
102
+ keep ??= new Set(newChildNodes);
103
+ while (current !== endNode && !keep.has(current) && !takesOver(current, newChildNode)) {
104
+ const next = current.nextSibling;
105
+ parentNode.removeChild(current);
106
+ current = next;
107
+ }
99
108
  }
100
-
101
- // If DOM node is equal to the new node, don't do anything in the DOM
102
- if (oldChildNode === newChildNode || oldChildNode.isEqualNode(newChildNode)) {
103
- continue;
109
+ if (current === newChildNode || (current !== endNode && !keep.has(current))) {
110
+ // in place already, or a stale node the new one takes over
111
+ current = current.nextSibling;
112
+ } else {
113
+ parentNode.insertBefore(newChildNode, current);
104
114
  }
105
-
106
- // Everything else is somehow different and can be replaced
107
- parentNode.replaceChild(newChildNode, oldChildNode);
115
+ }
116
+ while (current !== endNode) {
117
+ const next = current.nextSibling;
118
+ parentNode.removeChild(current);
119
+ current = next;
108
120
  }
109
121
  };
110
122
 
@@ -116,16 +128,13 @@ function getType(value) {
116
128
  return 'text';
117
129
  }
118
130
 
119
- // TODO: handle funcitons?! Should be handled in parseArray!!!
131
+ // the parsed items of an array as the child nodes to diff: template parts contribute their nodes, primitives become
132
+ // text nodes, DOM nodes pass through
120
133
  const unwrapArray = (nodes) => {
121
- // we have to unwrap any complex objects inside the array so that we can diff arrays of childNodes
122
134
  return nodes.flatMap((value) => {
123
135
  if (value instanceof TemplatePart) {
124
136
  return value.childNodes;
125
137
  }
126
- if (Array.isArray(value)) {
127
- return unwrapArray(value);
128
- }
129
138
  if (typeof value === 'boolean' || typeof value === 'number' || typeof value === 'string') {
130
139
  return document.createTextNode(value);
131
140
  }
@@ -134,76 +143,72 @@ const unwrapArray = (nodes) => {
134
143
  };
135
144
 
136
145
  /**
137
- * @param {Comment} comment
138
- * @param {TemplatePart | any[] | any} initialValue
139
- * @param {boolean} serverSideRendered
140
- * @return {(function(*): void)|*}
146
+ * Array items get the same treatment as a single value: functions are resolved completely, arrays nested in the array
147
+ * are flattened and null or undefined items are dropped, which is the list the server renders. An array that needs
148
+ * none of it is returned as is.
149
+ * @param {any[]} values
150
+ * @return {any[]}
151
+ */
152
+ const resolveArrayItems = (values) => {
153
+ if (!values.some((value) => typeof value === 'function' || Array.isArray(value) || value == null)) {
154
+ return values;
155
+ }
156
+ return values.flatMap((item) => {
157
+ let value = item;
158
+ while (typeof value === 'function') value = value();
159
+ if (Array.isArray(value)) return resolveArrayItems(value);
160
+ return value == null ? [] : [value];
161
+ });
162
+ };
163
+
164
+ /**
165
+ * Creates the processor that writes a parsed value between the markers of a child node part.
166
+ * @param {PartMarkers} markers - the start and end marker of the part
167
+ * @return {(function(TemplatePart | any[] | any, boolean=): void)}
141
168
  */
142
- export const processNodePart = (comment, initialValue, serverSideRendered = false) => {
169
+ export const processNodePart = ({ startNode, endNode }) => {
143
170
  let oldValueType = 'empty';
144
171
  let oldValue = undefined;
145
- // this is for string values to be inserted into the DOM. A cached TextNode will be used so that we don't have to constantly create new DOM nodes.
146
- // TODO: when server side rendered, we need to get the text node from the DOM no?!
172
+ // string values are rendered into one text node that is reused across updates. Hydration takes over the server
173
+ // rendered text node as that node
147
174
  let cachedTextNode = globalThis.document?.createTextNode('');
148
175
 
149
- if (serverSideRendered) {
150
- oldValueType = getType(initialValue);
151
- oldValue = initialValue instanceof TemplatePart ? initialValue.strings : initialValue;
152
- }
153
-
154
- const processNodeValue = (newValue) => {
155
- let newValueType = getType(newValue);
176
+ return (newValue, hydrate = false) => {
177
+ const newValueType = getType(newValue);
156
178
 
157
179
  if (oldValueType === newValueType && oldValue === newValue) return;
158
180
 
159
- if (oldValueType !== newValueType) {
160
- if (newValueType === 'empty') {
161
- removeNodesBetweenComments(comment);
181
+ if (newValueType === 'empty') {
182
+ removeNodesBetweenComments(endNode);
183
+ } else if (newValueType === 'text') {
184
+ if (oldValueType !== 'text') {
185
+ const serverTextNode = startNode.nextSibling;
186
+ if (hydrate && serverTextNode.nodeType === TEXT_NODE && serverTextNode.nextSibling === endNode) {
187
+ cachedTextNode = serverTextNode;
188
+ } else {
189
+ replaceNodesBetweenComments(endNode, [cachedTextNode]);
190
+ }
191
+ }
192
+ if (cachedTextNode.data !== String(newValue)) cachedTextNode.data = newValue;
193
+ } else if (newValueType === 'templatePart') {
194
+ // a template part bound to server rendered DOM (hydration) is in place already and is left alone
195
+ if (newValue.childNodes[0].parentNode !== endNode.parentNode) {
196
+ replaceNodesBetweenComments(endNode, newValue.childNodes);
197
+ }
198
+ } else {
199
+ const nodes = newValueType === 'array' ? unwrapArray(newValue) : [newValue];
200
+ // a type change replaces what is there. Server rendered nodes (hydration) go through the diff instead,
201
+ // nodes that are in place already, for example a focused input, stay untouched
202
+ if (oldValueType !== newValueType && !hydrate) {
203
+ replaceNodesBetweenComments(endNode, nodes);
162
204
  } else {
163
- const getNodesForType = (value, type) => {
164
- if (type === 'templatePart') return value.childNodes;
165
- if (type === 'array') return unwrapArray(value);
166
- if (type === 'node') return [value];
167
-
168
- // Every else falls back to rendering a text node
169
- cachedTextNode.data = value;
170
- return [cachedTextNode];
171
- };
172
- replaceNodesBetweenComments(comment, getNodesForType(newValue, newValueType));
205
+ diffChildNodes(nodes, startNode, endNode);
173
206
  }
174
-
175
- oldValueType = newValueType;
176
- oldValue = newValue;
177
- return;
178
- }
179
-
180
- if (newValueType === 'text') {
181
- cachedTextNode.data = newValue;
182
-
183
- oldValue = newValue;
184
- return;
185
- }
186
-
187
- if (newValueType === 'array') {
188
- diffChildNodes(unwrapArray(newValue), comment);
189
-
190
- oldValue = newValue;
191
- return;
192
207
  }
193
208
 
194
- if (newValueType === 'node') {
195
- diffChildNodes([newValue], comment);
196
-
197
- oldValue = newValue;
198
- }
209
+ oldValueType = newValueType;
210
+ oldValue = newValue;
199
211
  };
200
-
201
- if (!serverSideRendered) {
202
- // initial rendering
203
- processNodeValue(initialValue);
204
- }
205
-
206
- return processNodeValue;
207
212
  };
208
213
 
209
214
  /**
@@ -216,17 +221,16 @@ export class ChildNodePart extends Part {
216
221
  /** @type {TemplatePart|undefined} */
217
222
  templatePart;
218
223
 
219
- /** @type {Part[]} */
224
+ /** @type {TemplatePart[]} indexed like the items of an array value, only template items have a part */
220
225
  arrayParts = [];
221
226
 
222
227
  /**
223
228
  * @param {Node} startNode - the start comment marker node
224
- * @param {any | any[]} value
225
229
  */
226
- constructor(startNode, value) {
230
+ constructor(startNode) {
227
231
  if (!startNode || startNode?.nodeType !== COMMENT_NODE) {
228
232
  throw new Error(
229
- startNode ? 'ChildNodePart: startNode is undefined' : 'ChildNodePart: startNode is not a comment node',
233
+ startNode ? 'ChildNodePart: startNode is not a comment node' : 'ChildNodePart: startNode is undefined',
230
234
  );
231
235
  }
232
236
 
@@ -234,70 +238,63 @@ export class ChildNodePart extends Part {
234
238
 
235
239
  startNode.__part = this; // add Part to comment node for debugging in the browser
236
240
  this.markers = PartMarkers.createFromStartNode(startNode);
237
-
238
- // value can become array | TemplatePart | any
239
- const initialValue = this.parseValue(value);
240
-
241
- this.processor = processNodePart(this.markers?.endNode, initialValue, this.markers?.serverSideRendered);
241
+ this.processor = processNodePart(this.markers);
242
242
  }
243
243
 
244
244
  /**
245
- * @param {any} value
246
- * @return {*}
245
+ * @param {TemplateResult | any[] | any} value
246
+ * @param {boolean} [hydrate] - bind nested template parts to the server rendered markers instead of cloning
247
247
  */
248
- update(value) {
249
- const parsedValue = this.parseValue(value);
250
-
251
- if (value instanceof TemplateResult || Array.isArray(value)) {
252
- this.updateParts(value);
253
- }
254
-
255
- this.processor?.(parsedValue);
248
+ update(value, hydrate = false) {
249
+ // functions are resolved completely and array items are normalized, parsing and updating have to work on the
250
+ // same value
251
+ let resolvedValue = value;
252
+ while (typeof resolvedValue === 'function') resolvedValue = resolvedValue();
253
+ if (Array.isArray(resolvedValue)) resolvedValue = resolveArrayItems(resolvedValue);
254
+ const parsedValue = this.parseValue(resolvedValue, hydrate);
255
+ this.updateParts(resolvedValue, hydrate);
256
+ this.processor(parsedValue, hydrate);
256
257
  }
257
258
 
258
259
  /**
259
- * @param {TemplateResult | any[]} value
260
+ * @param {TemplateResult | any[] | any} value
261
+ * @param {boolean} [hydrate]
260
262
  */
261
- updateParts(value) {
263
+ updateParts(value, hydrate = false) {
262
264
  if (value instanceof TemplateResult) {
263
- this.templatePart.update(value);
265
+ this.templatePart.update(value, hydrate);
264
266
  }
265
267
  if (Array.isArray(value)) {
266
268
  for (let index = 0; index < value.length; index++) {
267
- this.arrayParts[index]?.update(value[index]);
269
+ if (value[index] instanceof TemplateResult) this.arrayParts[index].update(value[index], hydrate);
268
270
  }
269
271
  }
270
272
  }
271
273
 
272
274
  /**
275
+ * Creates the nested parts for a value. When hydrating, nested template parts are bound to the server rendered
276
+ * template-part markers between this part's markers.
273
277
  * @param {TemplateResult | any[] | any} value
278
+ * @param {boolean} hydrate
274
279
  * @return {TemplatePart | Node | string | Array<TemplatePart | Node | string>}
275
280
  */
276
- parseValue(value) {
281
+ parseValue(value, hydrate = false) {
277
282
  if (value instanceof TemplateResult) {
278
283
  if (!this.templatePart) {
279
- this.templatePart = new TemplatePart(this.markers?.findNestedStartNode('template-part'), value);
280
- if (!this.markers?.serverSideRendered) {
281
- // INFO: this is similar to TemplateResult#237
282
- replaceNodesBetweenComments(this.markers?.endNode, this.templatePart.childNodes);
283
- }
284
+ const startNode = hydrate ? this.markers.findNestedStartNode('template-part') : undefined;
285
+ this.templatePart = new TemplatePart(startNode, value);
284
286
  } else if (this.templatePart.strings !== value.strings) {
285
287
  // this means we have a different TemplateResult here with maybe different parts also
286
288
  this.templatePart = new TemplatePart(undefined, value);
287
- // INFO: this is similar to TemplateResult#237
288
- replaceNodesBetweenComments(this.markers?.endNode, this.templatePart.childNodes);
289
289
  }
290
290
  return this.templatePart;
291
291
  }
292
292
  if (Array.isArray(value)) {
293
- return this.parseArray(value);
293
+ return this.parseArray(value, hydrate);
294
294
  }
295
295
  if (value?.nodeType) {
296
296
  return value;
297
297
  }
298
- if (typeof value === 'function') {
299
- return this.parseValue(value());
300
- }
301
298
  if (value === null || value === undefined) {
302
299
  return value;
303
300
  }
@@ -306,44 +303,32 @@ export class ChildNodePart extends Part {
306
303
  }
307
304
 
308
305
  /**
309
- * Nested TemplateResults values need to be unrolled in order for update functions to be able to process them
306
+ * Creates the template parts for the template items of an array, indexed like the items. A different template at
307
+ * an index gets a new part, the nodes of the part that was there go away with the diff. Other items (text, nodes)
308
+ * pass through, functions and nested arrays are resolved before, see update().
310
309
  * @param {any[]} values
311
- * @return {any[]}
310
+ * @param {boolean} hydrate
311
+ * @return {Array<TemplatePart | Node | string>}
312
312
  */
313
- parseArray(values) {
313
+ parseArray(values, hydrate = false) {
314
314
  const parsedValues = [];
315
- for (let index = 0; index < values.length; index++) {
316
- let value = values[index];
317
-
318
- // TODO: it would be better to use this.parseValue() here!!!
315
+ // the server rendered template-part markers directly between this part's markers, one per nested template.
316
+ // Other values (text, nodes) have no marker, so the templates are counted separately
317
+ const nestedStartNodes = hydrate ? this.markers.findNestedStartNodes('template-part') : [];
318
+ let nestedIndex = 0;
319
319
 
320
+ for (let index = 0; index < values.length; index++) {
321
+ const value = values[index];
320
322
  if (value instanceof TemplateResult) {
321
- // TODO this is probably wrong. Should only be taken from parts cache if it has the same type (eg. template vs string, mixed types arrays)
322
323
  let templatePart = this.arrayParts[index];
323
- if (!templatePart) {
324
- const templatePartCommentNodes = this.markers?.childNodes?.filter(
325
- (node) => node && node.nodeType === COMMENT_NODE && node.data === 'template-part',
326
- );
327
- const startNode = templatePartCommentNodes[index];
328
- templatePart = new TemplatePart(startNode, value);
324
+ if (!templatePart || templatePart.strings !== value.strings) {
325
+ templatePart = new TemplatePart(nestedStartNodes[nestedIndex], value);
329
326
  this.arrayParts[index] = templatePart;
330
327
  }
328
+ nestedIndex++;
331
329
  parsedValues[index] = templatePart;
332
- } else if (Array.isArray(value)) {
333
- let childNodePart = this.arrayParts[index];
334
- if (!childNodePart) {
335
- // TODO: this seems not correct :( (maybe Template vs DOM Parts !?)
336
- const templatePartCommentNodes = this.markers?.childNodes?.filter(
337
- (node) => node && node.nodeType === COMMENT_NODE && node.data === 'template-part',
338
- );
339
- const startNode = templatePartCommentNodes[index];
340
- childNodePart = new ChildNodePart(startNode, value);
341
- this.arrayParts[index] = childNodePart;
342
- }
343
- parsedValues[index] = childNodePart;
344
330
  } else {
345
- // TODO: what if the value from the function is a TemplatePart or an array?! Should we do this first thing?!
346
- parsedValues[index] = typeof value === 'function' ? value() : value;
331
+ parsedValues[index] = value;
347
332
  }
348
333
  }
349
334
 
@@ -5,8 +5,10 @@ export class Part {
5
5
  processor = undefined;
6
6
 
7
7
  /**
8
+ * Applies a value to the DOM.
8
9
  * @abstract
9
10
  * @param {TemplateResult | any[] | any} value
11
+ * @param {boolean} [hydrate] - first update of server rendered DOM, nested parts bind to the server rendered markers
10
12
  */
11
- update(value) {}
13
+ update(value, hydrate = false) {}
12
14
  }
@@ -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
  };
@@ -16,12 +16,11 @@ class GlobalStylesStore extends Store {
16
16
  }
17
17
 
18
18
  initMutationObserver() {
19
- const mutationObserver = new MutationObserver(async (mutationRecord) => {
20
- if (!mutationRecord[0]) return;
21
-
22
- const filteredNodes = Array.from(mutationRecord[0].addedNodes).filter(
23
- (node) => node.tagName === 'STYLE' || node.tagName === 'LINK',
24
- );
19
+ const mutationObserver = new MutationObserver((mutationRecords) => {
20
+ // a batch holds every mutation since the last callback, the styles can sit in any of its records
21
+ const filteredNodes = mutationRecords
22
+ .flatMap((record) => Array.from(record.addedNodes))
23
+ .filter((node) => node.tagName === 'STYLE' || node.tagName === 'LINK');
25
24
 
26
25
  filteredNodes.forEach((styleNode) => {
27
26
  if (styleNode.tagName === 'LINK') {