@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.
- package/docs/features/templates.md +19 -10
- package/package.json +5 -2
- package/src/TemplateElement.js +5 -3
- package/src/dom-parts/AttributePart.js +37 -7
- package/src/dom-parts/ChildNodePart.js +141 -156
- package/src/dom-parts/Part.js +3 -1
- package/src/dom-parts/PartMarkers.js +84 -26
- package/src/dom-parts/TemplatePart.js +160 -69
- package/src/dom-parts/TemplateResult.js +71 -84
- package/src/util/GlobalStylesStore.js +5 -6
|
@@ -158,27 +158,36 @@ const attributeString = spreadAttributes(attrs);
|
|
|
158
158
|
|
|
159
159
|
##### Writing custom directives
|
|
160
160
|
|
|
161
|
-
|
|
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
|
-
|
|
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
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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 ${
|
|
186
|
+
return html` <div ${highlight(this.active)}>Highlighted while active.</div> `;
|
|
178
187
|
}
|
|
179
188
|
```
|
|
180
189
|
|
|
181
|
-
|
|
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.
|
|
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"
|
package/src/TemplateElement.js
CHANGED
|
@@ -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
|
-
|
|
59
|
-
if (firstRender) {
|
|
58
|
+
if (this._options.shadowRender && !this.shadowRoot) {
|
|
60
59
|
this.attachShadow({ mode: 'open' });
|
|
61
|
-
|
|
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
|
|
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
|
-
|
|
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)
|
|
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=${...}
|
|
86
|
-
if (name.startsWith('@')
|
|
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,
|
|
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
|
-
*
|
|
73
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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
|
-
//
|
|
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
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
* @
|
|
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 = (
|
|
169
|
+
export const processNodePart = ({ startNode, endNode }) => {
|
|
143
170
|
let oldValueType = 'empty';
|
|
144
171
|
let oldValue = undefined;
|
|
145
|
-
//
|
|
146
|
-
//
|
|
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
|
-
|
|
150
|
-
|
|
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 (
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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
|
-
|
|
195
|
-
|
|
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 {
|
|
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
|
|
230
|
+
constructor(startNode) {
|
|
227
231
|
if (!startNode || startNode?.nodeType !== COMMENT_NODE) {
|
|
228
232
|
throw new Error(
|
|
229
|
-
startNode ? 'ChildNodePart: startNode is
|
|
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
|
-
* @
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
this.
|
|
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]
|
|
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
|
-
|
|
280
|
-
|
|
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
|
-
*
|
|
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
|
-
* @
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
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
|
-
|
|
346
|
-
parsedValues[index] = typeof value === 'function' ? value() : value;
|
|
331
|
+
parsedValues[index] = value;
|
|
347
332
|
}
|
|
348
333
|
}
|
|
349
334
|
|
package/src/dom-parts/Part.js
CHANGED
|
@@ -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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
35
|
-
* @return {
|
|
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 =
|
|
38
|
-
return this.
|
|
87
|
+
findNestedStartNode(partType = TEMPLATE_MARKER) {
|
|
88
|
+
return this.findNestedStartNodes(partType)[0];
|
|
39
89
|
}
|
|
40
90
|
|
|
41
91
|
/**
|
|
42
|
-
* Creates a
|
|
43
|
-
*
|
|
44
|
-
* @
|
|
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
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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 {
|
|
32
|
-
* @param {TemplateResult}
|
|
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,
|
|
35
|
-
if (startNode && startNode
|
|
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.
|
|
44
|
-
this.childNodes = this.markers.childNodes;
|
|
84
|
+
this.hydrated = this.bindLive(startNode, templateResult);
|
|
45
85
|
}
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
this.
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
if (
|
|
89
|
-
|
|
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
|
-
|
|
93
|
-
this.childNodes = importedFragment.childNodes;
|
|
132
|
+
}
|
|
94
133
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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.
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
229
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
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(
|
|
326
|
+
return attribute(attributeName, value, quotes);
|
|
295
327
|
},
|
|
296
328
|
},
|
|
297
329
|
];
|
|
298
330
|
}
|
|
299
331
|
|
|
300
|
-
if (name.startsWith('@')
|
|
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
|
-
|
|
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
|
-
*
|
|
373
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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(
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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') {
|