zero-query 1.1.1 → 1.2.2

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 (156) 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 +442 -442
  5. package/cli/commands/build.js +253 -247
  6. package/cli/commands/bundle.js +1226 -1224
  7. package/cli/commands/create.js +178 -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 +441 -0
  73. package/cli/scaffold/webrtc/assets/.gitkeep +0 -0
  74. package/cli/scaffold/webrtc/global.css +250 -0
  75. package/cli/scaffold/webrtc/index.html +21 -0
  76. package/cli/scaffold/webrtc/package.json +16 -0
  77. package/cli/scaffold/webrtc/server/index.js +198 -0
  78. package/cli/utils.js +305 -287
  79. package/dist/API.md +661 -0
  80. package/dist/zquery.dist.zip +0 -0
  81. package/dist/zquery.js +10454 -6614
  82. package/dist/zquery.min.js +8 -631
  83. package/index.d.ts +570 -371
  84. package/index.js +311 -240
  85. package/package.json +77 -70
  86. package/src/component.js +1758 -1691
  87. package/src/core.js +929 -921
  88. package/src/diff.js +497 -497
  89. package/src/errors.js +209 -209
  90. package/src/expression.js +929 -922
  91. package/src/http.js +242 -242
  92. package/src/package.json +1 -1
  93. package/src/reactive.js +271 -255
  94. package/src/router.js +878 -843
  95. package/src/ssr.js +421 -418
  96. package/src/store.js +325 -318
  97. package/src/utils.js +534 -515
  98. package/src/webrtc/e2ee.js +351 -0
  99. package/src/webrtc/errors.js +116 -0
  100. package/src/webrtc/ice.js +301 -0
  101. package/src/webrtc/index.js +131 -0
  102. package/src/webrtc/joinToken.js +119 -0
  103. package/src/webrtc/observe.js +172 -0
  104. package/src/webrtc/peer.js +351 -0
  105. package/src/webrtc/reactive.js +268 -0
  106. package/src/webrtc/room.js +625 -0
  107. package/src/webrtc/sdp.js +302 -0
  108. package/src/webrtc/sfu/index.js +43 -0
  109. package/src/webrtc/sfu/livekit.js +131 -0
  110. package/src/webrtc/sfu/mediasoup.js +150 -0
  111. package/src/webrtc/signaling.js +373 -0
  112. package/src/webrtc/turn.js +237 -0
  113. package/tests/_helpers/webrtcFakes.js +289 -0
  114. package/tests/audit.test.js +4158 -4158
  115. package/tests/bench/render.bench.js +77 -0
  116. package/tests/cli.test.js +1136 -1103
  117. package/tests/compare.test.js +497 -486
  118. package/tests/component.test.js +4076 -3938
  119. package/tests/core.test.js +1926 -1910
  120. package/tests/dev-server.test.js +489 -489
  121. package/tests/diff.test.js +1416 -1416
  122. package/tests/docs.test.js +1664 -1650
  123. package/tests/electron-features.test.js +864 -864
  124. package/tests/errors.test.js +619 -619
  125. package/tests/expression.test.js +1087 -1056
  126. package/tests/http.test.js +693 -648
  127. package/tests/reactive.test.js +844 -819
  128. package/tests/router.test.js +2423 -2327
  129. package/tests/ssr.test.js +916 -870
  130. package/tests/store.test.js +840 -830
  131. package/tests/test-minifier.js +153 -153
  132. package/tests/test-ssr.js +27 -27
  133. package/tests/utils.test.js +1411 -1377
  134. package/tests/webrtc/e2ee.test.js +283 -0
  135. package/tests/webrtc/ice.test.js +202 -0
  136. package/tests/webrtc/joinToken.test.js +89 -0
  137. package/tests/webrtc/observe.test.js +111 -0
  138. package/tests/webrtc/peer.test.js +373 -0
  139. package/tests/webrtc/reactive.test.js +235 -0
  140. package/tests/webrtc/room.test.js +406 -0
  141. package/tests/webrtc/sdp.test.js +151 -0
  142. package/tests/webrtc/sfu-livekit.test.js +119 -0
  143. package/tests/webrtc/sfu.test.js +160 -0
  144. package/tests/webrtc/signaling.test.js +251 -0
  145. package/tests/webrtc/turn.test.js +256 -0
  146. package/types/collection.d.ts +383 -383
  147. package/types/component.d.ts +186 -186
  148. package/types/errors.d.ts +135 -135
  149. package/types/http.d.ts +92 -92
  150. package/types/misc.d.ts +201 -201
  151. package/types/reactive.d.ts +98 -98
  152. package/types/router.d.ts +190 -190
  153. package/types/ssr.d.ts +102 -102
  154. package/types/store.d.ts +146 -146
  155. package/types/utils.d.ts +245 -245
  156. 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
+ }