zero-query 1.1.1 → 1.2.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.
Files changed (154) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +2 -0
  3. package/cli/args.js +33 -33
  4. package/cli/commands/build-api.js +443 -442
  5. package/cli/commands/build.js +254 -247
  6. package/cli/commands/bundle.js +1228 -1224
  7. package/cli/commands/create.js +137 -121
  8. package/cli/commands/dev/devtools/index.js +56 -56
  9. package/cli/commands/dev/devtools/js/components.js +49 -49
  10. package/cli/commands/dev/devtools/js/core.js +423 -423
  11. package/cli/commands/dev/devtools/js/elements.js +421 -421
  12. package/cli/commands/dev/devtools/js/network.js +166 -166
  13. package/cli/commands/dev/devtools/js/performance.js +73 -73
  14. package/cli/commands/dev/devtools/js/router.js +105 -105
  15. package/cli/commands/dev/devtools/js/source.js +132 -132
  16. package/cli/commands/dev/devtools/js/stats.js +35 -35
  17. package/cli/commands/dev/devtools/js/tabs.js +79 -79
  18. package/cli/commands/dev/devtools/panel.html +95 -95
  19. package/cli/commands/dev/devtools/styles.css +244 -244
  20. package/cli/commands/dev/index.js +107 -107
  21. package/cli/commands/dev/logger.js +75 -75
  22. package/cli/commands/dev/overlay.js +858 -858
  23. package/cli/commands/dev/server.js +220 -220
  24. package/cli/commands/dev/validator.js +94 -94
  25. package/cli/commands/dev/watcher.js +172 -172
  26. package/cli/help.js +114 -112
  27. package/cli/index.js +52 -52
  28. package/cli/scaffold/default/LICENSE +21 -21
  29. package/cli/scaffold/default/app/app.js +207 -207
  30. package/cli/scaffold/default/app/components/about.js +201 -201
  31. package/cli/scaffold/default/app/components/api-demo.js +143 -143
  32. package/cli/scaffold/default/app/components/contact-card.js +231 -231
  33. package/cli/scaffold/default/app/components/contacts/contacts.css +706 -706
  34. package/cli/scaffold/default/app/components/contacts/contacts.html +200 -200
  35. package/cli/scaffold/default/app/components/contacts/contacts.js +196 -196
  36. package/cli/scaffold/default/app/components/counter.js +127 -127
  37. package/cli/scaffold/default/app/components/home.js +249 -249
  38. package/cli/scaffold/default/app/components/not-found.js +16 -16
  39. package/cli/scaffold/default/app/components/playground/playground.css +115 -115
  40. package/cli/scaffold/default/app/components/playground/playground.html +161 -161
  41. package/cli/scaffold/default/app/components/playground/playground.js +116 -116
  42. package/cli/scaffold/default/app/components/todos.js +225 -225
  43. package/cli/scaffold/default/app/components/toolkit/toolkit.css +97 -97
  44. package/cli/scaffold/default/app/components/toolkit/toolkit.html +146 -146
  45. package/cli/scaffold/default/app/components/toolkit/toolkit.js +280 -280
  46. package/cli/scaffold/default/app/routes.js +15 -15
  47. package/cli/scaffold/default/app/store.js +101 -101
  48. package/cli/scaffold/default/global.css +552 -552
  49. package/cli/scaffold/default/index.html +99 -99
  50. package/cli/scaffold/minimal/app/app.js +85 -85
  51. package/cli/scaffold/minimal/app/components/about.js +68 -68
  52. package/cli/scaffold/minimal/app/components/counter.js +122 -122
  53. package/cli/scaffold/minimal/app/components/home.js +68 -68
  54. package/cli/scaffold/minimal/app/components/not-found.js +16 -16
  55. package/cli/scaffold/minimal/app/routes.js +9 -9
  56. package/cli/scaffold/minimal/app/store.js +36 -36
  57. package/cli/scaffold/minimal/global.css +300 -300
  58. package/cli/scaffold/minimal/index.html +44 -44
  59. package/cli/scaffold/ssr/app/app.js +41 -41
  60. package/cli/scaffold/ssr/app/components/about.js +55 -55
  61. package/cli/scaffold/ssr/app/components/blog/index.js +65 -65
  62. package/cli/scaffold/ssr/app/components/blog/post.js +86 -86
  63. package/cli/scaffold/ssr/app/components/home.js +37 -37
  64. package/cli/scaffold/ssr/app/components/not-found.js +15 -15
  65. package/cli/scaffold/ssr/app/routes.js +8 -8
  66. package/cli/scaffold/ssr/global.css +228 -228
  67. package/cli/scaffold/ssr/index.html +37 -37
  68. package/cli/scaffold/ssr/package.json +8 -8
  69. package/cli/scaffold/ssr/server/data/posts.js +144 -144
  70. package/cli/scaffold/ssr/server/index.js +213 -213
  71. package/cli/scaffold/webrtc/app/app.js +11 -0
  72. package/cli/scaffold/webrtc/app/components/video-room.js +295 -0
  73. package/cli/scaffold/webrtc/app/lib/room.js +252 -0
  74. package/cli/scaffold/webrtc/assets/.gitkeep +0 -0
  75. package/cli/scaffold/webrtc/global.css +250 -0
  76. package/cli/scaffold/webrtc/index.html +21 -0
  77. package/cli/utils.js +305 -287
  78. package/dist/API.md +661 -0
  79. package/dist/zquery.dist.zip +0 -0
  80. package/dist/zquery.js +10313 -6614
  81. package/dist/zquery.min.js +8 -631
  82. package/index.d.ts +570 -371
  83. package/index.js +311 -240
  84. package/package.json +76 -70
  85. package/src/component.js +1709 -1691
  86. package/src/core.js +921 -921
  87. package/src/diff.js +497 -497
  88. package/src/errors.js +209 -209
  89. package/src/expression.js +922 -922
  90. package/src/http.js +242 -242
  91. package/src/package.json +1 -1
  92. package/src/reactive.js +255 -255
  93. package/src/router.js +843 -843
  94. package/src/ssr.js +418 -418
  95. package/src/store.js +318 -318
  96. package/src/utils.js +515 -515
  97. package/src/webrtc/e2ee.js +351 -0
  98. package/src/webrtc/errors.js +116 -0
  99. package/src/webrtc/ice.js +301 -0
  100. package/src/webrtc/index.js +131 -0
  101. package/src/webrtc/joinToken.js +119 -0
  102. package/src/webrtc/observe.js +172 -0
  103. package/src/webrtc/peer.js +351 -0
  104. package/src/webrtc/reactive.js +268 -0
  105. package/src/webrtc/room.js +625 -0
  106. package/src/webrtc/sdp.js +302 -0
  107. package/src/webrtc/sfu/index.js +43 -0
  108. package/src/webrtc/sfu/livekit.js +131 -0
  109. package/src/webrtc/sfu/mediasoup.js +150 -0
  110. package/src/webrtc/signaling.js +373 -0
  111. package/src/webrtc/turn.js +237 -0
  112. package/tests/_helpers/webrtcFakes.js +289 -0
  113. package/tests/audit.test.js +4158 -4158
  114. package/tests/cli.test.js +1136 -1103
  115. package/tests/compare.test.js +497 -486
  116. package/tests/component.test.js +3969 -3938
  117. package/tests/core.test.js +1910 -1910
  118. package/tests/dev-server.test.js +489 -489
  119. package/tests/diff.test.js +1416 -1416
  120. package/tests/docs.test.js +1664 -1650
  121. package/tests/electron-features.test.js +864 -864
  122. package/tests/errors.test.js +619 -619
  123. package/tests/expression.test.js +1056 -1056
  124. package/tests/http.test.js +648 -648
  125. package/tests/reactive.test.js +819 -819
  126. package/tests/router.test.js +2327 -2327
  127. package/tests/ssr.test.js +870 -870
  128. package/tests/store.test.js +830 -830
  129. package/tests/test-minifier.js +153 -153
  130. package/tests/test-ssr.js +27 -27
  131. package/tests/utils.test.js +1377 -1377
  132. package/tests/webrtc/e2ee.test.js +283 -0
  133. package/tests/webrtc/ice.test.js +202 -0
  134. package/tests/webrtc/joinToken.test.js +89 -0
  135. package/tests/webrtc/observe.test.js +111 -0
  136. package/tests/webrtc/peer.test.js +373 -0
  137. package/tests/webrtc/reactive.test.js +235 -0
  138. package/tests/webrtc/room.test.js +406 -0
  139. package/tests/webrtc/sdp.test.js +151 -0
  140. package/tests/webrtc/sfu-livekit.test.js +119 -0
  141. package/tests/webrtc/sfu.test.js +160 -0
  142. package/tests/webrtc/signaling.test.js +251 -0
  143. package/tests/webrtc/turn.test.js +256 -0
  144. package/types/collection.d.ts +383 -383
  145. package/types/component.d.ts +186 -186
  146. package/types/errors.d.ts +135 -135
  147. package/types/http.d.ts +92 -92
  148. package/types/misc.d.ts +201 -201
  149. package/types/reactive.d.ts +98 -98
  150. package/types/router.d.ts +190 -190
  151. package/types/ssr.d.ts +102 -102
  152. package/types/store.d.ts +146 -146
  153. package/types/utils.d.ts +245 -245
  154. package/types/webrtc.d.ts +653 -0
package/src/diff.js CHANGED
@@ -1,497 +1,497 @@
1
- /**
2
- * zQuery Diff - Lightweight DOM morphing engine
3
- *
4
- * Patches an existing DOM tree to match new HTML without destroying nodes
5
- * that haven't changed. Preserves focus, scroll positions, third-party
6
- * widget state, video playback, and other live DOM state.
7
- *
8
- * Approach: walk old and new trees in parallel, reconcile node by node.
9
- * Keyed elements (via `z-key`) get matched across position changes.
10
- *
11
- * Performance advantages over virtual DOM (React/Angular):
12
- * - No virtual tree allocation or diffing - works directly on real DOM
13
- * - Skips unchanged subtrees via fast isEqualNode() check
14
- * - z-skip attribute to opt out of diffing entire subtrees
15
- * - Reuses a single template element for HTML parsing (zero GC pressure)
16
- * - Keyed reconciliation uses LIS (Longest Increasing Subsequence) to
17
- * minimize DOM moves - same algorithm as Vue 3 / ivi
18
- * - Minimal attribute diffing with early bail-out
19
- */
20
-
21
- // ---------------------------------------------------------------------------
22
- // Reusable template element - avoids per-call allocation
23
- // ---------------------------------------------------------------------------
24
- let _tpl = null;
25
-
26
- function _getTemplate() {
27
- if (!_tpl) _tpl = document.createElement('template');
28
- return _tpl;
29
- }
30
-
31
- // ---------------------------------------------------------------------------
32
- // morph(existingRoot, newHTML) - patch existing DOM to match newHTML
33
- // ---------------------------------------------------------------------------
34
-
35
- /**
36
- * Morph an existing DOM element's children to match new HTML.
37
- * Only touches nodes that actually differ.
38
- *
39
- * @param {Element} rootEl - The live DOM container to patch
40
- * @param {string} newHTML - The desired HTML string
41
- */
42
- export function morph(rootEl, newHTML) {
43
- const start = typeof window !== 'undefined' && window.__zqMorphHook ? performance.now() : 0;
44
- const tpl = _getTemplate();
45
- tpl.innerHTML = newHTML;
46
- const newRoot = tpl.content;
47
-
48
- // Move children into a wrapper for consistent handling.
49
- // We move (not clone) from the template - cheaper than cloning.
50
- const tempDiv = document.createElement('div');
51
- while (newRoot.firstChild) tempDiv.appendChild(newRoot.firstChild);
52
-
53
- _morphChildren(rootEl, tempDiv);
54
-
55
- if (start) window.__zqMorphHook(rootEl, performance.now() - start);
56
- }
57
-
58
- /**
59
- * Morph a single element in place - diffs attributes and children
60
- * without replacing the node reference. Useful for replaceWith-style
61
- * updates where you want to keep the element identity when the tag
62
- * name matches.
63
- *
64
- * If the new HTML produces a different tag, falls back to native replace.
65
- *
66
- * @param {Element} oldEl - The live DOM element to patch
67
- * @param {string} newHTML - HTML string for the replacement element
68
- * @returns {Element} - The resulting element (same ref if morphed, new if replaced)
69
- */
70
- export function morphElement(oldEl, newHTML) {
71
- const start = typeof window !== 'undefined' && window.__zqMorphHook ? performance.now() : 0;
72
- const tpl = _getTemplate();
73
- tpl.innerHTML = newHTML;
74
- const newEl = tpl.content.firstElementChild;
75
- if (!newEl) return oldEl;
76
-
77
- // Same tag - morph in place (preserves identity, event listeners, refs)
78
- if (oldEl.nodeName === newEl.nodeName) {
79
- _morphAttributes(oldEl, newEl);
80
- _morphChildren(oldEl, newEl);
81
- if (start) window.__zqMorphHook(oldEl, performance.now() - start);
82
- return oldEl;
83
- }
84
-
85
- // Different tag - must replace (can't morph <div> into <span>)
86
- const clone = newEl.cloneNode(true);
87
- oldEl.parentNode.replaceChild(clone, oldEl);
88
- if (start) window.__zqMorphHook(clone, performance.now() - start);
89
- return clone;
90
- }
91
-
92
- // Aliases for the concat build - core.js imports these as _morph / _morphElement,
93
- // but the build strips `import … as` lines, so the aliases must exist at runtime.
94
- const _morph = morph;
95
- const _morphElement = morphElement;
96
-
97
- /**
98
- * Reconcile children of `oldParent` to match `newParent`.
99
- *
100
- * @param {Element} oldParent - live DOM parent
101
- * @param {Element} newParent - desired state parent
102
- */
103
- function _morphChildren(oldParent, newParent) {
104
- // Snapshot live NodeLists into arrays - childNodes is live and
105
- // mutates during insertBefore/removeChild. Using a for loop to push
106
- // avoids spread operator overhead for large child lists.
107
- const oldCN = oldParent.childNodes;
108
- const newCN = newParent.childNodes;
109
- const oldLen = oldCN.length;
110
- const newLen = newCN.length;
111
- const oldChildren = new Array(oldLen);
112
- const newChildren = new Array(newLen);
113
- for (let i = 0; i < oldLen; i++) oldChildren[i] = oldCN[i];
114
- for (let i = 0; i < newLen; i++) newChildren[i] = newCN[i];
115
-
116
- // Scan for keyed elements - only build maps if keys exist
117
- let hasKeys = false;
118
- let oldKeyMap, newKeyMap;
119
-
120
- for (let i = 0; i < oldLen; i++) {
121
- if (_getKey(oldChildren[i]) != null) { hasKeys = true; break; }
122
- }
123
- if (!hasKeys) {
124
- for (let i = 0; i < newLen; i++) {
125
- if (_getKey(newChildren[i]) != null) { hasKeys = true; break; }
126
- }
127
- }
128
-
129
- if (hasKeys) {
130
- oldKeyMap = new Map();
131
- newKeyMap = new Map();
132
- for (let i = 0; i < oldLen; i++) {
133
- const key = _getKey(oldChildren[i]);
134
- if (key != null) oldKeyMap.set(key, i);
135
- }
136
- for (let i = 0; i < newLen; i++) {
137
- const key = _getKey(newChildren[i]);
138
- if (key != null) newKeyMap.set(key, i);
139
- }
140
- _morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, newKeyMap);
141
- } else {
142
- _morphChildrenUnkeyed(oldParent, oldChildren, newChildren);
143
- }
144
- }
145
-
146
- /**
147
- * Unkeyed reconciliation - positional matching.
148
- */
149
- function _morphChildrenUnkeyed(oldParent, oldChildren, newChildren) {
150
- const oldLen = oldChildren.length;
151
- const newLen = newChildren.length;
152
- const minLen = oldLen < newLen ? oldLen : newLen;
153
-
154
- // Morph overlapping range
155
- for (let i = 0; i < minLen; i++) {
156
- _morphNode(oldParent, oldChildren[i], newChildren[i]);
157
- }
158
-
159
- // Append new nodes
160
- if (newLen > oldLen) {
161
- for (let i = oldLen; i < newLen; i++) {
162
- oldParent.appendChild(newChildren[i].cloneNode(true));
163
- }
164
- }
165
-
166
- // Remove excess old nodes (iterate backwards to avoid index shifting)
167
- if (oldLen > newLen) {
168
- for (let i = oldLen - 1; i >= newLen; i--) {
169
- oldParent.removeChild(oldChildren[i]);
170
- }
171
- }
172
- }
173
-
174
- /**
175
- * Keyed reconciliation - match by z-key, reorder with minimal moves
176
- * using Longest Increasing Subsequence (LIS) to find the maximum set
177
- * of nodes that are already in the correct relative order, then only
178
- * move the remaining nodes.
179
- */
180
- function _morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, newKeyMap) {
181
- const consumed = new Set();
182
- const newLen = newChildren.length;
183
- const matched = new Array(newLen);
184
-
185
- // Step 1: Match new children to old children by key
186
- for (let i = 0; i < newLen; i++) {
187
- const key = _getKey(newChildren[i]);
188
- if (key != null && oldKeyMap.has(key)) {
189
- const oldIdx = oldKeyMap.get(key);
190
- matched[i] = oldChildren[oldIdx];
191
- consumed.add(oldIdx);
192
- } else {
193
- matched[i] = null;
194
- }
195
- }
196
-
197
- // Step 2: Remove old keyed nodes not in the new tree
198
- for (let i = oldChildren.length - 1; i >= 0; i--) {
199
- if (!consumed.has(i)) {
200
- const key = _getKey(oldChildren[i]);
201
- if (key != null && !newKeyMap.has(key)) {
202
- oldParent.removeChild(oldChildren[i]);
203
- }
204
- }
205
- }
206
-
207
- // Step 3: Build index array for LIS of matched old indices.
208
- // This finds the largest set of keyed nodes already in order,
209
- // so we only need to move the rest - O(n log n) instead of O(n²).
210
- const oldIndices = []; // Maps new-position → old-position (or -1)
211
- for (let i = 0; i < newLen; i++) {
212
- if (matched[i]) {
213
- const key = _getKey(newChildren[i]);
214
- oldIndices.push(oldKeyMap.get(key));
215
- } else {
216
- oldIndices.push(-1);
217
- }
218
- }
219
-
220
- const lisSet = _lis(oldIndices);
221
-
222
- // Step 4: Insert / reorder / morph - walk new children forward,
223
- // using LIS to decide which nodes stay in place.
224
- let cursor = oldParent.firstChild;
225
- const unkeyedOld = [];
226
- for (let i = 0; i < oldChildren.length; i++) {
227
- if (!consumed.has(i) && _getKey(oldChildren[i]) == null) {
228
- unkeyedOld.push(oldChildren[i]);
229
- }
230
- }
231
- let unkeyedIdx = 0;
232
-
233
- for (let i = 0; i < newLen; i++) {
234
- const newNode = newChildren[i];
235
- const newKey = _getKey(newNode);
236
- let oldNode = matched[i];
237
-
238
- if (!oldNode && newKey == null) {
239
- oldNode = unkeyedOld[unkeyedIdx++] || null;
240
- }
241
-
242
- if (oldNode) {
243
- // If this node is NOT part of the LIS, it needs to be moved
244
- if (!lisSet.has(i)) {
245
- oldParent.insertBefore(oldNode, cursor);
246
- }
247
- // Capture next sibling BEFORE _morphNode - if _morphNode calls
248
- // replaceChild, oldNode is removed and nextSibling becomes stale.
249
- const nextSib = oldNode.nextSibling;
250
- _morphNode(oldParent, oldNode, newNode);
251
- cursor = nextSib;
252
- } else {
253
- // Insert new node
254
- const clone = newNode.cloneNode(true);
255
- if (cursor) {
256
- oldParent.insertBefore(clone, cursor);
257
- } else {
258
- oldParent.appendChild(clone);
259
- }
260
- }
261
- }
262
-
263
- // Remove remaining unkeyed old nodes
264
- while (unkeyedIdx < unkeyedOld.length) {
265
- const leftover = unkeyedOld[unkeyedIdx++];
266
- if (leftover.parentNode === oldParent) {
267
- oldParent.removeChild(leftover);
268
- }
269
- }
270
-
271
- // Remove any remaining keyed old nodes that weren't consumed
272
- for (let i = 0; i < oldChildren.length; i++) {
273
- if (!consumed.has(i)) {
274
- const node = oldChildren[i];
275
- if (node.parentNode === oldParent && _getKey(node) != null && !newKeyMap.has(_getKey(node))) {
276
- oldParent.removeChild(node);
277
- }
278
- }
279
- }
280
- }
281
-
282
- /**
283
- * Compute the Longest Increasing Subsequence of an index array.
284
- * Returns a Set of positions (in the input) that form the LIS.
285
- * Entries with value -1 (unmatched) are excluded.
286
- *
287
- * O(n log n) - same algorithm used by Vue 3 and ivi.
288
- *
289
- * @param {number[]} arr - array of old-tree indices (-1 = unmatched)
290
- * @returns {Set<number>} - positions in arr belonging to the LIS
291
- */
292
- function _lis(arr) {
293
- const len = arr.length;
294
- const result = new Set();
295
- if (len === 0) return result;
296
-
297
- // tails[i] = index in arr of the smallest tail element for LIS of length i+1
298
- const tails = [];
299
- // prev[i] = predecessor index in arr for the LIS ending at arr[i]
300
- const prev = new Array(len).fill(-1);
301
- const tailIndices = []; // parallel to tails: actual positions
302
-
303
- for (let i = 0; i < len; i++) {
304
- if (arr[i] === -1) continue;
305
- const val = arr[i];
306
-
307
- // Binary search for insertion point in tails
308
- let lo = 0, hi = tails.length;
309
- while (lo < hi) {
310
- const mid = (lo + hi) >> 1;
311
- if (tails[mid] < val) lo = mid + 1;
312
- else hi = mid;
313
- }
314
-
315
- tails[lo] = val;
316
- tailIndices[lo] = i;
317
- prev[i] = lo > 0 ? tailIndices[lo - 1] : -1;
318
- }
319
-
320
- // Reconstruct: walk backwards from the last element of LIS
321
- let k = tailIndices[tails.length - 1];
322
- for (let i = tails.length - 1; i >= 0; i--) {
323
- result.add(k);
324
- k = prev[k];
325
- }
326
-
327
- return result;
328
- }
329
-
330
- /**
331
- * Morph a single node in place.
332
- */
333
- function _morphNode(parent, oldNode, newNode) {
334
- // Text / comment nodes - just update content
335
- if (oldNode.nodeType === 3 || oldNode.nodeType === 8) {
336
- if (newNode.nodeType === oldNode.nodeType) {
337
- if (oldNode.nodeValue !== newNode.nodeValue) {
338
- oldNode.nodeValue = newNode.nodeValue;
339
- }
340
- return;
341
- }
342
- // Different node types - replace
343
- parent.replaceChild(newNode.cloneNode(true), oldNode);
344
- return;
345
- }
346
-
347
- // Different node types or tag names - replace entirely
348
- if (oldNode.nodeType !== newNode.nodeType ||
349
- oldNode.nodeName !== newNode.nodeName) {
350
- parent.replaceChild(newNode.cloneNode(true), oldNode);
351
- return;
352
- }
353
-
354
- // Both are elements - diff attributes then recurse children
355
- if (oldNode.nodeType === 1) {
356
- // z-skip: developer opt-out - skip diffing this subtree entirely.
357
- // Useful for third-party widgets, canvas, video, or large static content.
358
- if (oldNode.hasAttribute('z-skip')) return;
359
-
360
- // Fast bail-out: if the elements are identical, skip everything.
361
- // isEqualNode() is a native C++ comparison - much faster than walking
362
- // attributes + children in JS when trees haven't changed.
363
- if (oldNode.isEqualNode(newNode)) return;
364
-
365
- _morphAttributes(oldNode, newNode);
366
-
367
- // Special elements: don't recurse into their children
368
- const tag = oldNode.nodeName;
369
- if (tag === 'INPUT') {
370
- _syncInputValue(oldNode, newNode);
371
- return;
372
- }
373
- if (tag === 'TEXTAREA') {
374
- if (oldNode.value !== newNode.textContent) {
375
- oldNode.value = newNode.textContent || '';
376
- }
377
- return;
378
- }
379
- if (tag === 'SELECT') {
380
- _morphChildren(oldNode, newNode);
381
- if (oldNode.value !== newNode.value) {
382
- oldNode.value = newNode.value;
383
- }
384
- return;
385
- }
386
-
387
- // Generic element - recurse children
388
- _morphChildren(oldNode, newNode);
389
- }
390
- }
391
-
392
- /**
393
- * Sync attributes from newEl onto oldEl.
394
- * Uses a single pass: build a set of new attribute names, iterate
395
- * old attrs for removals, then apply new attrs.
396
- */
397
- function _morphAttributes(oldEl, newEl) {
398
- const newAttrs = newEl.attributes;
399
- const oldAttrs = oldEl.attributes;
400
- const newLen = newAttrs.length;
401
- const oldLen = oldAttrs.length;
402
-
403
- // Fast path: if both have same number of attributes, check if they're identical
404
- if (newLen === oldLen) {
405
- let same = true;
406
- for (let i = 0; i < newLen; i++) {
407
- const na = newAttrs[i];
408
- if (oldEl.getAttribute(na.name) !== na.value) { same = false; break; }
409
- }
410
- if (same) {
411
- // Also verify no extra old attrs (names mismatch)
412
- for (let i = 0; i < oldLen; i++) {
413
- if (!newEl.hasAttribute(oldAttrs[i].name)) { same = false; break; }
414
- }
415
- }
416
- if (same) return;
417
- }
418
-
419
- // Build set of new attr names for O(1) lookup during removal pass
420
- const newNames = new Set();
421
- for (let i = 0; i < newLen; i++) {
422
- const attr = newAttrs[i];
423
- newNames.add(attr.name);
424
- if (oldEl.getAttribute(attr.name) !== attr.value) {
425
- oldEl.setAttribute(attr.name, attr.value);
426
- }
427
- }
428
-
429
- // Remove stale attributes - snapshot names first because oldAttrs
430
- // is a live NamedNodeMap that mutates on removeAttribute().
431
- const oldNames = new Array(oldLen);
432
- for (let i = 0; i < oldLen; i++) oldNames[i] = oldAttrs[i].name;
433
- for (let i = oldNames.length - 1; i >= 0; i--) {
434
- if (!newNames.has(oldNames[i])) {
435
- oldEl.removeAttribute(oldNames[i]);
436
- }
437
- }
438
- }
439
-
440
- /**
441
- * Sync input element value, checked, disabled states.
442
- *
443
- * Only updates the value when the new HTML explicitly carries a `value`
444
- * attribute. Templates that use z-model manage values through reactive
445
- * state + _bindModels - the morph engine should not interfere by wiping
446
- * a live input's content to '' just because the template has no `value`
447
- * attr. This prevents the wipe-then-restore cycle that resets cursor
448
- * position on every keystroke.
449
- */
450
- function _syncInputValue(oldEl, newEl) {
451
- const type = (oldEl.type || '').toLowerCase();
452
-
453
- if (type === 'checkbox' || type === 'radio') {
454
- if (oldEl.checked !== newEl.checked) oldEl.checked = newEl.checked;
455
- } else {
456
- const newVal = newEl.getAttribute('value');
457
- if (newVal !== null && oldEl.value !== newVal) {
458
- oldEl.value = newVal;
459
- }
460
- }
461
-
462
- // Sync disabled
463
- if (oldEl.disabled !== newEl.disabled) oldEl.disabled = newEl.disabled;
464
- }
465
-
466
- /**
467
- * Get the reconciliation key from a node.
468
- *
469
- * Priority: z-key attribute → id attribute → data-id / data-key.
470
- * Auto-detected keys use a `\0` prefix to avoid collisions with
471
- * explicit z-key values.
472
- *
473
- * This means the LIS-optimised keyed path activates automatically
474
- * whenever elements carry `id` or `data-id` / `data-key` attributes
475
- * - no extra markup required.
476
- *
477
- * @returns {string|null}
478
- */
479
- function _getKey(node) {
480
- if (node.nodeType !== 1) return null;
481
-
482
- // Explicit z-key - highest priority
483
- const zk = node.getAttribute('z-key');
484
- if (zk) return zk;
485
-
486
- // Auto-key: id attribute (unique by spec)
487
- if (node.id) return '\0id:' + node.id;
488
-
489
- // Auto-key: data-id or data-key attributes
490
- const ds = node.dataset;
491
- if (ds) {
492
- if (ds.id) return '\0data-id:' + ds.id;
493
- if (ds.key) return '\0data-key:' + ds.key;
494
- }
495
-
496
- return null;
497
- }
1
+ /**
2
+ * zQuery Diff - Lightweight DOM morphing engine
3
+ *
4
+ * Patches an existing DOM tree to match new HTML without destroying nodes
5
+ * that haven't changed. Preserves focus, scroll positions, third-party
6
+ * widget state, video playback, and other live DOM state.
7
+ *
8
+ * Approach: walk old and new trees in parallel, reconcile node by node.
9
+ * Keyed elements (via `z-key`) get matched across position changes.
10
+ *
11
+ * Performance advantages over virtual DOM (React/Angular):
12
+ * - No virtual tree allocation or diffing - works directly on real DOM
13
+ * - Skips unchanged subtrees via fast isEqualNode() check
14
+ * - z-skip attribute to opt out of diffing entire subtrees
15
+ * - Reuses a single template element for HTML parsing (zero GC pressure)
16
+ * - Keyed reconciliation uses LIS (Longest Increasing Subsequence) to
17
+ * minimize DOM moves - same algorithm as Vue 3 / ivi
18
+ * - Minimal attribute diffing with early bail-out
19
+ */
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Reusable template element - avoids per-call allocation
23
+ // ---------------------------------------------------------------------------
24
+ let _tpl = null;
25
+
26
+ function _getTemplate() {
27
+ if (!_tpl) _tpl = document.createElement('template');
28
+ return _tpl;
29
+ }
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // morph(existingRoot, newHTML) - patch existing DOM to match newHTML
33
+ // ---------------------------------------------------------------------------
34
+
35
+ /**
36
+ * Morph an existing DOM element's children to match new HTML.
37
+ * Only touches nodes that actually differ.
38
+ *
39
+ * @param {Element} rootEl - The live DOM container to patch
40
+ * @param {string} newHTML - The desired HTML string
41
+ */
42
+ export function morph(rootEl, newHTML) {
43
+ const start = typeof window !== 'undefined' && window.__zqMorphHook ? performance.now() : 0;
44
+ const tpl = _getTemplate();
45
+ tpl.innerHTML = newHTML;
46
+ const newRoot = tpl.content;
47
+
48
+ // Move children into a wrapper for consistent handling.
49
+ // We move (not clone) from the template - cheaper than cloning.
50
+ const tempDiv = document.createElement('div');
51
+ while (newRoot.firstChild) tempDiv.appendChild(newRoot.firstChild);
52
+
53
+ _morphChildren(rootEl, tempDiv);
54
+
55
+ if (start) window.__zqMorphHook(rootEl, performance.now() - start);
56
+ }
57
+
58
+ /**
59
+ * Morph a single element in place - diffs attributes and children
60
+ * without replacing the node reference. Useful for replaceWith-style
61
+ * updates where you want to keep the element identity when the tag
62
+ * name matches.
63
+ *
64
+ * If the new HTML produces a different tag, falls back to native replace.
65
+ *
66
+ * @param {Element} oldEl - The live DOM element to patch
67
+ * @param {string} newHTML - HTML string for the replacement element
68
+ * @returns {Element} - The resulting element (same ref if morphed, new if replaced)
69
+ */
70
+ export function morphElement(oldEl, newHTML) {
71
+ const start = typeof window !== 'undefined' && window.__zqMorphHook ? performance.now() : 0;
72
+ const tpl = _getTemplate();
73
+ tpl.innerHTML = newHTML;
74
+ const newEl = tpl.content.firstElementChild;
75
+ if (!newEl) return oldEl;
76
+
77
+ // Same tag - morph in place (preserves identity, event listeners, refs)
78
+ if (oldEl.nodeName === newEl.nodeName) {
79
+ _morphAttributes(oldEl, newEl);
80
+ _morphChildren(oldEl, newEl);
81
+ if (start) window.__zqMorphHook(oldEl, performance.now() - start);
82
+ return oldEl;
83
+ }
84
+
85
+ // Different tag - must replace (can't morph <div> into <span>)
86
+ const clone = newEl.cloneNode(true);
87
+ oldEl.parentNode.replaceChild(clone, oldEl);
88
+ if (start) window.__zqMorphHook(clone, performance.now() - start);
89
+ return clone;
90
+ }
91
+
92
+ // Aliases for the concat build - core.js imports these as _morph / _morphElement,
93
+ // but the build strips `import … as` lines, so the aliases must exist at runtime.
94
+ const _morph = morph;
95
+ const _morphElement = morphElement;
96
+
97
+ /**
98
+ * Reconcile children of `oldParent` to match `newParent`.
99
+ *
100
+ * @param {Element} oldParent - live DOM parent
101
+ * @param {Element} newParent - desired state parent
102
+ */
103
+ function _morphChildren(oldParent, newParent) {
104
+ // Snapshot live NodeLists into arrays - childNodes is live and
105
+ // mutates during insertBefore/removeChild. Using a for loop to push
106
+ // avoids spread operator overhead for large child lists.
107
+ const oldCN = oldParent.childNodes;
108
+ const newCN = newParent.childNodes;
109
+ const oldLen = oldCN.length;
110
+ const newLen = newCN.length;
111
+ const oldChildren = new Array(oldLen);
112
+ const newChildren = new Array(newLen);
113
+ for (let i = 0; i < oldLen; i++) oldChildren[i] = oldCN[i];
114
+ for (let i = 0; i < newLen; i++) newChildren[i] = newCN[i];
115
+
116
+ // Scan for keyed elements - only build maps if keys exist
117
+ let hasKeys = false;
118
+ let oldKeyMap, newKeyMap;
119
+
120
+ for (let i = 0; i < oldLen; i++) {
121
+ if (_getKey(oldChildren[i]) != null) { hasKeys = true; break; }
122
+ }
123
+ if (!hasKeys) {
124
+ for (let i = 0; i < newLen; i++) {
125
+ if (_getKey(newChildren[i]) != null) { hasKeys = true; break; }
126
+ }
127
+ }
128
+
129
+ if (hasKeys) {
130
+ oldKeyMap = new Map();
131
+ newKeyMap = new Map();
132
+ for (let i = 0; i < oldLen; i++) {
133
+ const key = _getKey(oldChildren[i]);
134
+ if (key != null) oldKeyMap.set(key, i);
135
+ }
136
+ for (let i = 0; i < newLen; i++) {
137
+ const key = _getKey(newChildren[i]);
138
+ if (key != null) newKeyMap.set(key, i);
139
+ }
140
+ _morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, newKeyMap);
141
+ } else {
142
+ _morphChildrenUnkeyed(oldParent, oldChildren, newChildren);
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Unkeyed reconciliation - positional matching.
148
+ */
149
+ function _morphChildrenUnkeyed(oldParent, oldChildren, newChildren) {
150
+ const oldLen = oldChildren.length;
151
+ const newLen = newChildren.length;
152
+ const minLen = oldLen < newLen ? oldLen : newLen;
153
+
154
+ // Morph overlapping range
155
+ for (let i = 0; i < minLen; i++) {
156
+ _morphNode(oldParent, oldChildren[i], newChildren[i]);
157
+ }
158
+
159
+ // Append new nodes
160
+ if (newLen > oldLen) {
161
+ for (let i = oldLen; i < newLen; i++) {
162
+ oldParent.appendChild(newChildren[i].cloneNode(true));
163
+ }
164
+ }
165
+
166
+ // Remove excess old nodes (iterate backwards to avoid index shifting)
167
+ if (oldLen > newLen) {
168
+ for (let i = oldLen - 1; i >= newLen; i--) {
169
+ oldParent.removeChild(oldChildren[i]);
170
+ }
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Keyed reconciliation - match by z-key, reorder with minimal moves
176
+ * using Longest Increasing Subsequence (LIS) to find the maximum set
177
+ * of nodes that are already in the correct relative order, then only
178
+ * move the remaining nodes.
179
+ */
180
+ function _morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, newKeyMap) {
181
+ const consumed = new Set();
182
+ const newLen = newChildren.length;
183
+ const matched = new Array(newLen);
184
+
185
+ // Step 1: Match new children to old children by key
186
+ for (let i = 0; i < newLen; i++) {
187
+ const key = _getKey(newChildren[i]);
188
+ if (key != null && oldKeyMap.has(key)) {
189
+ const oldIdx = oldKeyMap.get(key);
190
+ matched[i] = oldChildren[oldIdx];
191
+ consumed.add(oldIdx);
192
+ } else {
193
+ matched[i] = null;
194
+ }
195
+ }
196
+
197
+ // Step 2: Remove old keyed nodes not in the new tree
198
+ for (let i = oldChildren.length - 1; i >= 0; i--) {
199
+ if (!consumed.has(i)) {
200
+ const key = _getKey(oldChildren[i]);
201
+ if (key != null && !newKeyMap.has(key)) {
202
+ oldParent.removeChild(oldChildren[i]);
203
+ }
204
+ }
205
+ }
206
+
207
+ // Step 3: Build index array for LIS of matched old indices.
208
+ // This finds the largest set of keyed nodes already in order,
209
+ // so we only need to move the rest - O(n log n) instead of O(n²).
210
+ const oldIndices = []; // Maps new-position → old-position (or -1)
211
+ for (let i = 0; i < newLen; i++) {
212
+ if (matched[i]) {
213
+ const key = _getKey(newChildren[i]);
214
+ oldIndices.push(oldKeyMap.get(key));
215
+ } else {
216
+ oldIndices.push(-1);
217
+ }
218
+ }
219
+
220
+ const lisSet = _lis(oldIndices);
221
+
222
+ // Step 4: Insert / reorder / morph - walk new children forward,
223
+ // using LIS to decide which nodes stay in place.
224
+ let cursor = oldParent.firstChild;
225
+ const unkeyedOld = [];
226
+ for (let i = 0; i < oldChildren.length; i++) {
227
+ if (!consumed.has(i) && _getKey(oldChildren[i]) == null) {
228
+ unkeyedOld.push(oldChildren[i]);
229
+ }
230
+ }
231
+ let unkeyedIdx = 0;
232
+
233
+ for (let i = 0; i < newLen; i++) {
234
+ const newNode = newChildren[i];
235
+ const newKey = _getKey(newNode);
236
+ let oldNode = matched[i];
237
+
238
+ if (!oldNode && newKey == null) {
239
+ oldNode = unkeyedOld[unkeyedIdx++] || null;
240
+ }
241
+
242
+ if (oldNode) {
243
+ // If this node is NOT part of the LIS, it needs to be moved
244
+ if (!lisSet.has(i)) {
245
+ oldParent.insertBefore(oldNode, cursor);
246
+ }
247
+ // Capture next sibling BEFORE _morphNode - if _morphNode calls
248
+ // replaceChild, oldNode is removed and nextSibling becomes stale.
249
+ const nextSib = oldNode.nextSibling;
250
+ _morphNode(oldParent, oldNode, newNode);
251
+ cursor = nextSib;
252
+ } else {
253
+ // Insert new node
254
+ const clone = newNode.cloneNode(true);
255
+ if (cursor) {
256
+ oldParent.insertBefore(clone, cursor);
257
+ } else {
258
+ oldParent.appendChild(clone);
259
+ }
260
+ }
261
+ }
262
+
263
+ // Remove remaining unkeyed old nodes
264
+ while (unkeyedIdx < unkeyedOld.length) {
265
+ const leftover = unkeyedOld[unkeyedIdx++];
266
+ if (leftover.parentNode === oldParent) {
267
+ oldParent.removeChild(leftover);
268
+ }
269
+ }
270
+
271
+ // Remove any remaining keyed old nodes that weren't consumed
272
+ for (let i = 0; i < oldChildren.length; i++) {
273
+ if (!consumed.has(i)) {
274
+ const node = oldChildren[i];
275
+ if (node.parentNode === oldParent && _getKey(node) != null && !newKeyMap.has(_getKey(node))) {
276
+ oldParent.removeChild(node);
277
+ }
278
+ }
279
+ }
280
+ }
281
+
282
+ /**
283
+ * Compute the Longest Increasing Subsequence of an index array.
284
+ * Returns a Set of positions (in the input) that form the LIS.
285
+ * Entries with value -1 (unmatched) are excluded.
286
+ *
287
+ * O(n log n) - same algorithm used by Vue 3 and ivi.
288
+ *
289
+ * @param {number[]} arr - array of old-tree indices (-1 = unmatched)
290
+ * @returns {Set<number>} - positions in arr belonging to the LIS
291
+ */
292
+ function _lis(arr) {
293
+ const len = arr.length;
294
+ const result = new Set();
295
+ if (len === 0) return result;
296
+
297
+ // tails[i] = index in arr of the smallest tail element for LIS of length i+1
298
+ const tails = [];
299
+ // prev[i] = predecessor index in arr for the LIS ending at arr[i]
300
+ const prev = new Array(len).fill(-1);
301
+ const tailIndices = []; // parallel to tails: actual positions
302
+
303
+ for (let i = 0; i < len; i++) {
304
+ if (arr[i] === -1) continue;
305
+ const val = arr[i];
306
+
307
+ // Binary search for insertion point in tails
308
+ let lo = 0, hi = tails.length;
309
+ while (lo < hi) {
310
+ const mid = (lo + hi) >> 1;
311
+ if (tails[mid] < val) lo = mid + 1;
312
+ else hi = mid;
313
+ }
314
+
315
+ tails[lo] = val;
316
+ tailIndices[lo] = i;
317
+ prev[i] = lo > 0 ? tailIndices[lo - 1] : -1;
318
+ }
319
+
320
+ // Reconstruct: walk backwards from the last element of LIS
321
+ let k = tailIndices[tails.length - 1];
322
+ for (let i = tails.length - 1; i >= 0; i--) {
323
+ result.add(k);
324
+ k = prev[k];
325
+ }
326
+
327
+ return result;
328
+ }
329
+
330
+ /**
331
+ * Morph a single node in place.
332
+ */
333
+ function _morphNode(parent, oldNode, newNode) {
334
+ // Text / comment nodes - just update content
335
+ if (oldNode.nodeType === 3 || oldNode.nodeType === 8) {
336
+ if (newNode.nodeType === oldNode.nodeType) {
337
+ if (oldNode.nodeValue !== newNode.nodeValue) {
338
+ oldNode.nodeValue = newNode.nodeValue;
339
+ }
340
+ return;
341
+ }
342
+ // Different node types - replace
343
+ parent.replaceChild(newNode.cloneNode(true), oldNode);
344
+ return;
345
+ }
346
+
347
+ // Different node types or tag names - replace entirely
348
+ if (oldNode.nodeType !== newNode.nodeType ||
349
+ oldNode.nodeName !== newNode.nodeName) {
350
+ parent.replaceChild(newNode.cloneNode(true), oldNode);
351
+ return;
352
+ }
353
+
354
+ // Both are elements - diff attributes then recurse children
355
+ if (oldNode.nodeType === 1) {
356
+ // z-skip: developer opt-out - skip diffing this subtree entirely.
357
+ // Useful for third-party widgets, canvas, video, or large static content.
358
+ if (oldNode.hasAttribute('z-skip')) return;
359
+
360
+ // Fast bail-out: if the elements are identical, skip everything.
361
+ // isEqualNode() is a native C++ comparison - much faster than walking
362
+ // attributes + children in JS when trees haven't changed.
363
+ if (oldNode.isEqualNode(newNode)) return;
364
+
365
+ _morphAttributes(oldNode, newNode);
366
+
367
+ // Special elements: don't recurse into their children
368
+ const tag = oldNode.nodeName;
369
+ if (tag === 'INPUT') {
370
+ _syncInputValue(oldNode, newNode);
371
+ return;
372
+ }
373
+ if (tag === 'TEXTAREA') {
374
+ if (oldNode.value !== newNode.textContent) {
375
+ oldNode.value = newNode.textContent || '';
376
+ }
377
+ return;
378
+ }
379
+ if (tag === 'SELECT') {
380
+ _morphChildren(oldNode, newNode);
381
+ if (oldNode.value !== newNode.value) {
382
+ oldNode.value = newNode.value;
383
+ }
384
+ return;
385
+ }
386
+
387
+ // Generic element - recurse children
388
+ _morphChildren(oldNode, newNode);
389
+ }
390
+ }
391
+
392
+ /**
393
+ * Sync attributes from newEl onto oldEl.
394
+ * Uses a single pass: build a set of new attribute names, iterate
395
+ * old attrs for removals, then apply new attrs.
396
+ */
397
+ function _morphAttributes(oldEl, newEl) {
398
+ const newAttrs = newEl.attributes;
399
+ const oldAttrs = oldEl.attributes;
400
+ const newLen = newAttrs.length;
401
+ const oldLen = oldAttrs.length;
402
+
403
+ // Fast path: if both have same number of attributes, check if they're identical
404
+ if (newLen === oldLen) {
405
+ let same = true;
406
+ for (let i = 0; i < newLen; i++) {
407
+ const na = newAttrs[i];
408
+ if (oldEl.getAttribute(na.name) !== na.value) { same = false; break; }
409
+ }
410
+ if (same) {
411
+ // Also verify no extra old attrs (names mismatch)
412
+ for (let i = 0; i < oldLen; i++) {
413
+ if (!newEl.hasAttribute(oldAttrs[i].name)) { same = false; break; }
414
+ }
415
+ }
416
+ if (same) return;
417
+ }
418
+
419
+ // Build set of new attr names for O(1) lookup during removal pass
420
+ const newNames = new Set();
421
+ for (let i = 0; i < newLen; i++) {
422
+ const attr = newAttrs[i];
423
+ newNames.add(attr.name);
424
+ if (oldEl.getAttribute(attr.name) !== attr.value) {
425
+ oldEl.setAttribute(attr.name, attr.value);
426
+ }
427
+ }
428
+
429
+ // Remove stale attributes - snapshot names first because oldAttrs
430
+ // is a live NamedNodeMap that mutates on removeAttribute().
431
+ const oldNames = new Array(oldLen);
432
+ for (let i = 0; i < oldLen; i++) oldNames[i] = oldAttrs[i].name;
433
+ for (let i = oldNames.length - 1; i >= 0; i--) {
434
+ if (!newNames.has(oldNames[i])) {
435
+ oldEl.removeAttribute(oldNames[i]);
436
+ }
437
+ }
438
+ }
439
+
440
+ /**
441
+ * Sync input element value, checked, disabled states.
442
+ *
443
+ * Only updates the value when the new HTML explicitly carries a `value`
444
+ * attribute. Templates that use z-model manage values through reactive
445
+ * state + _bindModels - the morph engine should not interfere by wiping
446
+ * a live input's content to '' just because the template has no `value`
447
+ * attr. This prevents the wipe-then-restore cycle that resets cursor
448
+ * position on every keystroke.
449
+ */
450
+ function _syncInputValue(oldEl, newEl) {
451
+ const type = (oldEl.type || '').toLowerCase();
452
+
453
+ if (type === 'checkbox' || type === 'radio') {
454
+ if (oldEl.checked !== newEl.checked) oldEl.checked = newEl.checked;
455
+ } else {
456
+ const newVal = newEl.getAttribute('value');
457
+ if (newVal !== null && oldEl.value !== newVal) {
458
+ oldEl.value = newVal;
459
+ }
460
+ }
461
+
462
+ // Sync disabled
463
+ if (oldEl.disabled !== newEl.disabled) oldEl.disabled = newEl.disabled;
464
+ }
465
+
466
+ /**
467
+ * Get the reconciliation key from a node.
468
+ *
469
+ * Priority: z-key attribute → id attribute → data-id / data-key.
470
+ * Auto-detected keys use a `\0` prefix to avoid collisions with
471
+ * explicit z-key values.
472
+ *
473
+ * This means the LIS-optimised keyed path activates automatically
474
+ * whenever elements carry `id` or `data-id` / `data-key` attributes
475
+ * - no extra markup required.
476
+ *
477
+ * @returns {string|null}
478
+ */
479
+ function _getKey(node) {
480
+ if (node.nodeType !== 1) return null;
481
+
482
+ // Explicit z-key - highest priority
483
+ const zk = node.getAttribute('z-key');
484
+ if (zk) return zk;
485
+
486
+ // Auto-key: id attribute (unique by spec)
487
+ if (node.id) return '\0id:' + node.id;
488
+
489
+ // Auto-key: data-id or data-key attributes
490
+ const ds = node.dataset;
491
+ if (ds) {
492
+ if (ds.id) return '\0data-id:' + ds.id;
493
+ if (ds.key) return '\0data-key:' + ds.key;
494
+ }
495
+
496
+ return null;
497
+ }