eleva 1.2.17-beta → 1.2.19-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,26 @@
1
1
  "use strict";
2
2
 
3
+ /**
4
+ * A regular expression to match hyphenated lowercase letters.
5
+ * @private
6
+ * @type {RegExp}
7
+ */
8
+ const CAMEL_RE = /-([a-z])/g;
9
+
3
10
  /**
4
11
  * @class 🎨 Renderer
5
- * @classdesc A DOM renderer that handles efficient DOM updates through patching and diffing.
6
- * Provides methods for updating the DOM by comparing new and old structures and applying
7
- * only the necessary changes, minimizing layout thrashing and improving performance.
12
+ * @classdesc A high-performance DOM renderer that implements an optimized direct DOM diffing algorithm.
13
+ *
14
+ * Key features:
15
+ * - Single-pass diffing algorithm for efficient DOM updates
16
+ * - Key-based node reconciliation for optimal performance
17
+ * - Intelligent attribute handling for ARIA, data attributes, and boolean properties
18
+ * - Preservation of special Eleva-managed instances and style elements
19
+ * - Memory-efficient with reusable temporary containers
20
+ *
21
+ * The renderer is designed to minimize DOM operations while maintaining
22
+ * exact attribute synchronization and proper node identity preservation.
23
+ * It's particularly optimized for frequent updates and complex DOM structures.
8
24
  *
9
25
  * @example
10
26
  * const renderer = new Renderer();
@@ -14,47 +30,46 @@
14
30
  */
15
31
  export class Renderer {
16
32
  /**
17
- * Creates a new Renderer instance with a reusable temporary container for parsing HTML.
33
+ * Creates a new Renderer instance.
18
34
  * @public
19
35
  */
20
36
  constructor() {
21
- /** @private {HTMLElement} Reusable temporary container for parsing new HTML */
37
+ /**
38
+ * A temporary container to hold the new HTML content while diffing.
39
+ * @private
40
+ * @type {HTMLElement}
41
+ */
22
42
  this._tempContainer = document.createElement("div");
23
43
  }
24
44
 
25
45
  /**
26
- * Patches the DOM of a container element with new HTML content.
27
- * Efficiently updates the DOM by parsing new HTML into a reusable container
28
- * and applying only the necessary changes.
46
+ * Patches the DOM of the given container with the provided HTML string.
29
47
  *
30
48
  * @public
31
49
  * @param {HTMLElement} container - The container element to patch.
32
- * @param {string} newHtml - The new HTML content to apply.
50
+ * @param {string} newHtml - The new HTML string.
33
51
  * @returns {void}
34
- * @throws {Error} If container is not an HTMLElement, newHtml is not a string, or patching fails.
52
+ * @throws {TypeError} If container is not an HTMLElement or newHtml is not a string.
53
+ * @throws {Error} If DOM patching fails.
35
54
  */
36
55
  patchDOM(container, newHtml) {
37
56
  if (!(container instanceof HTMLElement)) {
38
- throw new Error("Container must be an HTMLElement");
57
+ throw new TypeError("Container must be an HTMLElement");
39
58
  }
40
59
  if (typeof newHtml !== "string") {
41
- throw new Error("newHtml must be a string");
60
+ throw new TypeError("newHtml must be a string");
42
61
  }
43
62
 
44
63
  try {
45
- // Directly set new HTML, replacing any existing content
46
64
  this._tempContainer.innerHTML = newHtml;
47
-
48
65
  this._diff(container, this._tempContainer);
49
- } catch {
50
- throw new Error("Failed to patch DOM");
66
+ } catch (error) {
67
+ throw new Error(`Failed to patch DOM: ${error.message}`);
51
68
  }
52
69
  }
53
70
 
54
71
  /**
55
- * Diffs two DOM trees (old and new) and applies updates to the old DOM.
56
- * This method recursively compares nodes and their attributes, applying only
57
- * the necessary changes to minimize DOM operations.
72
+ * Performs a diff between two DOM nodes and patches the old node to match the new node.
58
73
  *
59
74
  * @private
60
75
  * @param {HTMLElement} oldParent - The original DOM element.
@@ -62,94 +77,127 @@ export class Renderer {
62
77
  * @returns {void}
63
78
  */
64
79
  _diff(oldParent, newParent) {
65
- if (oldParent.isEqualNode(newParent)) return;
80
+ if (oldParent === newParent || oldParent.isEqualNode?.(newParent)) return;
66
81
 
67
- const oldChildren = oldParent.childNodes;
68
- const newChildren = newParent.childNodes;
69
- const maxLength = Math.max(oldChildren.length, newChildren.length);
82
+ const oldChildren = Array.from(oldParent.childNodes);
83
+ const newChildren = Array.from(newParent.childNodes);
84
+ let oldStartIdx = 0,
85
+ newStartIdx = 0;
86
+ let oldEndIdx = oldChildren.length - 1;
87
+ let newEndIdx = newChildren.length - 1;
88
+ let oldKeyMap = null;
70
89
 
71
- for (let i = 0; i < maxLength; i++) {
72
- const oldNode = oldChildren[i];
73
- const newNode = newChildren[i];
90
+ while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
91
+ let oldStartNode = oldChildren[oldStartIdx];
92
+ let newStartNode = newChildren[newStartIdx];
74
93
 
75
- if (oldNode?._eleva_instance) {
76
- continue;
77
- }
94
+ if (!oldStartNode) {
95
+ oldStartNode = oldChildren[++oldStartIdx];
96
+ } else if (this._isSameNode(oldStartNode, newStartNode)) {
97
+ this._patchNode(oldStartNode, newStartNode);
98
+ oldStartIdx++;
99
+ newStartIdx++;
100
+ } else {
101
+ if (!oldKeyMap) {
102
+ oldKeyMap = this._createKeyMap(oldChildren, oldStartIdx, oldEndIdx);
103
+ }
104
+ const key = this._getNodeKey(newStartNode);
105
+ const oldNodeToMove = key ? oldKeyMap.get(key) : null;
78
106
 
79
- if (!oldNode && newNode) {
80
- oldParent.appendChild(newNode.cloneNode(true));
81
- continue;
82
- }
83
- if (oldNode && !newNode) {
84
- if (
85
- oldNode.nodeName === "STYLE" &&
86
- oldNode.hasAttribute("data-e-style")
87
- ) {
88
- continue;
107
+ if (oldNodeToMove) {
108
+ this._patchNode(oldNodeToMove, newStartNode);
109
+ oldParent.insertBefore(oldNodeToMove, oldStartNode);
110
+ oldChildren[oldChildren.indexOf(oldNodeToMove)] = null;
111
+ } else {
112
+ oldParent.insertBefore(newStartNode.cloneNode(true), oldStartNode);
89
113
  }
90
- oldParent.removeChild(oldNode);
91
- continue;
114
+ newStartIdx++;
92
115
  }
116
+ }
93
117
 
94
- const isSameType =
95
- oldNode.nodeType === newNode.nodeType &&
96
- oldNode.nodeName === newNode.nodeName;
97
-
98
- if (!isSameType) {
99
- oldParent.replaceChild(newNode.cloneNode(true), oldNode);
100
- continue;
118
+ if (oldStartIdx > oldEndIdx) {
119
+ const refNode = newChildren[newEndIdx + 1]
120
+ ? oldChildren[oldStartIdx]
121
+ : null;
122
+ for (let i = newStartIdx; i <= newEndIdx; i++) {
123
+ if (newChildren[i])
124
+ oldParent.insertBefore(newChildren[i].cloneNode(true), refNode);
125
+ }
126
+ } else if (newStartIdx > newEndIdx) {
127
+ for (let i = oldStartIdx; i <= oldEndIdx; i++) {
128
+ if (oldChildren[i]) this._removeNode(oldParent, oldChildren[i]);
101
129
  }
130
+ }
131
+ }
102
132
 
103
- if (oldNode.nodeType === Node.ELEMENT_NODE) {
104
- const oldKey = oldNode.getAttribute("key");
105
- const newKey = newNode.getAttribute("key");
133
+ /**
134
+ * Patches a single node.
135
+ *
136
+ * @private
137
+ * @param {Node} oldNode - The original DOM node.
138
+ * @param {Node} newNode - The new DOM node.
139
+ * @returns {void}
140
+ */
141
+ _patchNode(oldNode, newNode) {
142
+ if (oldNode?._eleva_instance) return;
106
143
 
107
- if (oldKey !== newKey && (oldKey || newKey)) {
108
- oldParent.replaceChild(newNode.cloneNode(true), oldNode);
109
- continue;
110
- }
144
+ if (!this._isSameNode(oldNode, newNode)) {
145
+ oldNode.replaceWith(newNode.cloneNode(true));
146
+ return;
147
+ }
111
148
 
112
- this._updateAttributes(oldNode, newNode);
113
- this._diff(oldNode, newNode);
114
- } else if (
115
- oldNode.nodeType === Node.TEXT_NODE &&
116
- oldNode.nodeValue !== newNode.nodeValue
117
- ) {
118
- oldNode.nodeValue = newNode.nodeValue;
119
- }
149
+ if (oldNode.nodeType === Node.ELEMENT_NODE) {
150
+ this._updateAttributes(oldNode, newNode);
151
+ this._diff(oldNode, newNode);
152
+ } else if (
153
+ oldNode.nodeType === Node.TEXT_NODE &&
154
+ oldNode.nodeValue !== newNode.nodeValue
155
+ ) {
156
+ oldNode.nodeValue = newNode.nodeValue;
120
157
  }
121
158
  }
122
159
 
123
160
  /**
124
- * Updates the attributes of an element to match those of a new element.
125
- * Handles special cases for ARIA attributes, data attributes, and boolean properties.
161
+ * Removes a node from its parent.
162
+ *
163
+ * @private
164
+ * @param {HTMLElement} parent - The parent element containing the node to remove.
165
+ * @param {Node} node - The node to remove.
166
+ * @returns {void}
167
+ */
168
+ _removeNode(parent, node) {
169
+ if (node.nodeName === "STYLE" && node.hasAttribute("data-e-style")) return;
170
+
171
+ parent.removeChild(node);
172
+ }
173
+
174
+ /**
175
+ * Updates the attributes of an element to match a new element's attributes.
126
176
  *
127
177
  * @private
128
- * @param {HTMLElement} oldEl - The element to update.
129
- * @param {HTMLElement} newEl - The element providing the updated attributes.
178
+ * @param {HTMLElement} oldEl - The original element to update.
179
+ * @param {HTMLElement} newEl - The new element to update.
130
180
  * @returns {void}
131
181
  */
132
182
  _updateAttributes(oldEl, newEl) {
133
183
  const oldAttrs = oldEl.attributes;
134
184
  const newAttrs = newEl.attributes;
135
185
 
136
- // Update/add new attributes
137
- for (const { name, value } of newAttrs) {
186
+ // Single pass for new/updated attributes
187
+ for (let i = 0; i < newAttrs.length; i++) {
188
+ const { name, value } = newAttrs[i];
138
189
  if (name.startsWith("@")) continue;
139
-
140
190
  if (oldEl.getAttribute(name) === value) continue;
141
-
142
191
  oldEl.setAttribute(name, value);
143
192
 
144
193
  if (name.startsWith("aria-")) {
145
194
  const prop =
146
- "aria" +
147
- name.slice(5).replace(/-([a-z])/g, (_, l) => l.toUpperCase());
195
+ "aria" + name.slice(5).replace(CAMEL_RE, (_, l) => l.toUpperCase());
148
196
  oldEl[prop] = value;
149
197
  } else if (name.startsWith("data-")) {
150
198
  oldEl.dataset[name.slice(5)] = value;
151
199
  } else {
152
- const prop = name.replace(/-([a-z])/g, (_, l) => l.toUpperCase());
200
+ const prop = name.replace(CAMEL_RE, (_, l) => l.toUpperCase());
153
201
  if (prop in oldEl) {
154
202
  const descriptor = Object.getOwnPropertyDescriptor(
155
203
  Object.getPrototypeOf(oldEl),
@@ -159,7 +207,6 @@ export class Renderer {
159
207
  typeof oldEl[prop] === "boolean" ||
160
208
  (descriptor?.get &&
161
209
  typeof descriptor.get.call(oldEl) === "boolean");
162
-
163
210
  if (isBoolean) {
164
211
  oldEl[prop] =
165
212
  value !== "false" &&
@@ -171,11 +218,74 @@ export class Renderer {
171
218
  }
172
219
  }
173
220
 
174
- // Remove old attributes
175
- for (const { name } of oldAttrs) {
221
+ // Remove any attributes no longer present
222
+ for (let i = oldAttrs.length - 1; i >= 0; i--) {
223
+ const name = oldAttrs[i].name;
176
224
  if (!newEl.hasAttribute(name)) {
177
225
  oldEl.removeAttribute(name);
178
226
  }
179
227
  }
180
228
  }
229
+
230
+ /**
231
+ * Determines if two nodes are the same based on their type, name, and key attributes.
232
+ *
233
+ * @private
234
+ * @param {Node} oldNode - The first node to compare.
235
+ * @param {Node} newNode - The second node to compare.
236
+ * @returns {boolean} True if the nodes are considered the same, false otherwise.
237
+ */
238
+ _isSameNode(oldNode, newNode) {
239
+ if (!oldNode || !newNode) return false;
240
+
241
+ const oldKey =
242
+ oldNode.nodeType === Node.ELEMENT_NODE
243
+ ? oldNode.getAttribute("key")
244
+ : null;
245
+ const newKey =
246
+ newNode.nodeType === Node.ELEMENT_NODE
247
+ ? newNode.getAttribute("key")
248
+ : null;
249
+
250
+ if (oldKey && newKey) return oldKey === newKey;
251
+
252
+ return (
253
+ !oldKey &&
254
+ !newKey &&
255
+ oldNode.nodeType === newNode.nodeType &&
256
+ oldNode.nodeName === newNode.nodeName
257
+ );
258
+ }
259
+
260
+ /**
261
+ * Creates a key map for the children of a parent node.
262
+ *
263
+ * @private
264
+ * @param {Array<Node>} children - The children of the parent node.
265
+ * @param {number} start - The start index of the children.
266
+ * @param {number} end - The end index of the children.
267
+ * @returns {Map<string, Node>} A key map for the children.
268
+ */
269
+ _createKeyMap(children, start, end) {
270
+ const map = new Map();
271
+ for (let i = start; i <= end; i++) {
272
+ const child = children[i];
273
+ const key = this._getNodeKey(child);
274
+ if (key) map.set(key, child);
275
+ }
276
+ return map;
277
+ }
278
+
279
+ /**
280
+ * Extracts the key attribute from a node if it exists.
281
+ *
282
+ * @private
283
+ * @param {Node} node - The node to extract the key from.
284
+ * @returns {string|null} The key attribute value or null if not found.
285
+ */
286
+ _getNodeKey(node) {
287
+ return node?.nodeType === Node.ELEMENT_NODE
288
+ ? node.getAttribute("key")
289
+ : null;
290
+ }
181
291
  }
@@ -5,6 +5,8 @@
5
5
  * @classdesc A reactive data holder that enables fine-grained reactivity in the Eleva framework.
6
6
  * Signals notify registered watchers when their value changes, enabling efficient DOM updates
7
7
  * through targeted patching rather than full re-renders.
8
+ * Updates are batched using microtasks to prevent multiple synchronous notifications.
9
+ * The class is generic, allowing type-safe handling of any value type T.
8
10
  *
9
11
  * @example
10
12
  * const count = new Signal(0);
@@ -17,12 +19,12 @@ export class Signal {
17
19
  * Creates a new Signal instance with the specified initial value.
18
20
  *
19
21
  * @public
20
- * @param {*} value - The initial value of the signal.
22
+ * @param {T} value - The initial value of the signal.
21
23
  */
22
24
  constructor(value) {
23
- /** @private {T} Internal storage for the signal's current value, where T is the type of the initial value */
25
+ /** @private {T} Internal storage for the signal's current value */
24
26
  this._value = value;
25
- /** @private {Set<function(T): void>} Collection of callback functions to be notified when value changes, where T is the value type */
27
+ /** @private {Set<(value: T) => void>} Collection of callback functions to be notified when value changes */
26
28
  this._watchers = new Set();
27
29
  /** @private {boolean} Flag to prevent multiple synchronous watcher notifications and batch updates into microtasks */
28
30
  this._pending = false;
@@ -32,7 +34,7 @@ export class Signal {
32
34
  * Gets the current value of the signal.
33
35
  *
34
36
  * @public
35
- * @returns {T} The current value, where T is the type of the initial value.
37
+ * @returns {T} The current value.
36
38
  */
37
39
  get value() {
38
40
  return this._value;
@@ -43,7 +45,7 @@ export class Signal {
43
45
  * The notification is batched using microtasks to prevent multiple synchronous updates.
44
46
  *
45
47
  * @public
46
- * @param {T} newVal - The new value to set, where T is the type of the initial value.
48
+ * @param {T} newVal - The new value to set.
47
49
  * @returns {void}
48
50
  */
49
51
  set value(newVal) {
@@ -58,8 +60,8 @@ export class Signal {
58
60
  * The watcher will receive the new value as its argument.
59
61
  *
60
62
  * @public
61
- * @param {function(T): void} fn - The callback function to invoke on value change, where T is the value type.
62
- * @returns {function(): boolean} A function to unsubscribe the watcher.
63
+ * @param {(value: T) => void} fn - The callback function to invoke on value change.
64
+ * @returns {() => boolean} A function to unsubscribe the watcher.
63
65
  * @example
64
66
  * const unsubscribe = signal.watch((value) => console.log(value));
65
67
  * // Later...
@@ -83,6 +85,7 @@ export class Signal {
83
85
 
84
86
  this._pending = true;
85
87
  queueMicrotask(() => {
88
+ /** @type {(fn: (value: T) => void) => void} */
86
89
  this._watchers.forEach((fn) => fn(this._value));
87
90
  this._pending = false;
88
91
  });
@@ -14,6 +14,7 @@
14
14
  export class TemplateEngine {
15
15
  /**
16
16
  * @private {RegExp} Regular expression for matching template expressions in the format {{ expression }}
17
+ * @type {RegExp}
17
18
  */
18
19
  static expressionPattern = /\{\{\s*(.*?)\s*\}\}/g;
19
20
 
@@ -24,7 +25,7 @@ export class TemplateEngine {
24
25
  * @public
25
26
  * @static
26
27
  * @param {string} template - The template string to parse.
27
- * @param {Object} data - The data context for evaluating expressions.
28
+ * @param {Record<string, unknown>} data - The data context for evaluating expressions.
28
29
  * @returns {string} The parsed template with expressions replaced by their values.
29
30
  * @example
30
31
  * const result = TemplateEngine.parse("{{user.name}} is {{user.age}} years old", {
@@ -41,12 +42,14 @@ export class TemplateEngine {
41
42
  /**
42
43
  * Evaluates an expression in the context of the provided data object.
43
44
  * Note: This does not provide a true sandbox and evaluated expressions may access global scope.
45
+ * The use of the `with` statement is necessary for expression evaluation but has security implications.
46
+ * Expressions should be carefully validated before evaluation.
44
47
  *
45
48
  * @public
46
49
  * @static
47
50
  * @param {string} expression - The expression to evaluate.
48
- * @param {Object} data - The data context for evaluation.
49
- * @returns {*} The result of the evaluation, or an empty string if evaluation fails.
51
+ * @param {Record<string, unknown>} data - The data context for evaluation.
52
+ * @returns {unknown} The result of the evaluation, or an empty string if evaluation fails.
50
53
  * @example
51
54
  * const result = TemplateEngine.evaluate("user.name", { user: { name: "John" } }); // Returns: "John"
52
55
  * const age = TemplateEngine.evaluate("user.age", { user: { age: 30 } }); // Returns: 30