zero-query 1.0.9 → 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.
- package/LICENSE +21 -21
- package/README.md +2 -0
- package/cli/args.js +33 -33
- package/cli/commands/build-api.js +443 -0
- package/cli/commands/build.js +254 -216
- package/cli/commands/bundle.js +1228 -1183
- package/cli/commands/create.js +137 -121
- package/cli/commands/dev/devtools/index.js +56 -56
- package/cli/commands/dev/devtools/js/components.js +49 -49
- package/cli/commands/dev/devtools/js/core.js +423 -423
- package/cli/commands/dev/devtools/js/elements.js +421 -421
- package/cli/commands/dev/devtools/js/network.js +166 -166
- package/cli/commands/dev/devtools/js/performance.js +73 -73
- package/cli/commands/dev/devtools/js/router.js +105 -105
- package/cli/commands/dev/devtools/js/source.js +132 -132
- package/cli/commands/dev/devtools/js/stats.js +35 -35
- package/cli/commands/dev/devtools/js/tabs.js +79 -79
- package/cli/commands/dev/devtools/panel.html +95 -95
- package/cli/commands/dev/devtools/styles.css +244 -244
- package/cli/commands/dev/index.js +107 -107
- package/cli/commands/dev/logger.js +75 -75
- package/cli/commands/dev/overlay.js +858 -858
- package/cli/commands/dev/server.js +220 -167
- package/cli/commands/dev/validator.js +94 -94
- package/cli/commands/dev/watcher.js +172 -172
- package/cli/help.js +114 -112
- package/cli/index.js +52 -52
- package/cli/scaffold/default/LICENSE +21 -21
- package/cli/scaffold/default/app/app.js +207 -207
- package/cli/scaffold/default/app/components/about.js +201 -201
- package/cli/scaffold/default/app/components/api-demo.js +143 -143
- package/cli/scaffold/default/app/components/contact-card.js +231 -231
- package/cli/scaffold/default/app/components/contacts/contacts.css +706 -706
- package/cli/scaffold/default/app/components/contacts/contacts.html +200 -200
- package/cli/scaffold/default/app/components/contacts/contacts.js +196 -196
- package/cli/scaffold/default/app/components/counter.js +127 -127
- package/cli/scaffold/default/app/components/home.js +249 -249
- package/cli/scaffold/default/app/components/not-found.js +16 -16
- package/cli/scaffold/default/app/components/playground/playground.css +115 -115
- package/cli/scaffold/default/app/components/playground/playground.html +161 -161
- package/cli/scaffold/default/app/components/playground/playground.js +116 -116
- package/cli/scaffold/default/app/components/todos.js +225 -225
- package/cli/scaffold/default/app/components/toolkit/toolkit.css +97 -97
- package/cli/scaffold/default/app/components/toolkit/toolkit.html +146 -146
- package/cli/scaffold/default/app/components/toolkit/toolkit.js +280 -280
- package/cli/scaffold/default/app/routes.js +15 -15
- package/cli/scaffold/default/app/store.js +101 -101
- package/cli/scaffold/default/global.css +552 -552
- package/cli/scaffold/default/index.html +99 -99
- package/cli/scaffold/minimal/app/app.js +85 -85
- package/cli/scaffold/minimal/app/components/about.js +68 -68
- package/cli/scaffold/minimal/app/components/counter.js +122 -122
- package/cli/scaffold/minimal/app/components/home.js +68 -68
- package/cli/scaffold/minimal/app/components/not-found.js +16 -16
- package/cli/scaffold/minimal/app/routes.js +9 -9
- package/cli/scaffold/minimal/app/store.js +36 -36
- package/cli/scaffold/minimal/global.css +300 -300
- package/cli/scaffold/minimal/index.html +44 -44
- package/cli/scaffold/ssr/app/app.js +41 -41
- package/cli/scaffold/ssr/app/components/about.js +55 -55
- package/cli/scaffold/ssr/app/components/blog/index.js +65 -65
- package/cli/scaffold/ssr/app/components/blog/post.js +86 -86
- package/cli/scaffold/ssr/app/components/home.js +37 -37
- package/cli/scaffold/ssr/app/components/not-found.js +15 -15
- package/cli/scaffold/ssr/app/routes.js +8 -8
- package/cli/scaffold/ssr/global.css +228 -228
- package/cli/scaffold/ssr/index.html +37 -37
- package/cli/scaffold/ssr/package.json +8 -8
- package/cli/scaffold/ssr/server/data/posts.js +144 -144
- package/cli/scaffold/ssr/server/index.js +213 -213
- package/cli/scaffold/webrtc/app/app.js +11 -0
- package/cli/scaffold/webrtc/app/components/video-room.js +295 -0
- package/cli/scaffold/webrtc/app/lib/room.js +252 -0
- package/cli/scaffold/webrtc/assets/.gitkeep +0 -0
- package/cli/scaffold/webrtc/global.css +250 -0
- package/cli/scaffold/webrtc/index.html +21 -0
- package/cli/utils.js +305 -287
- package/dist/API.md +7264 -0
- package/dist/zquery.dist.zip +0 -0
- package/dist/zquery.js +10313 -6252
- package/dist/zquery.min.js +8 -601
- package/index.d.ts +570 -365
- package/index.js +311 -232
- package/package.json +76 -69
- package/src/component.js +1709 -1454
- package/src/core.js +921 -921
- package/src/diff.js +497 -497
- package/src/errors.js +209 -209
- package/src/expression.js +922 -922
- package/src/http.js +242 -242
- package/src/package.json +1 -1
- package/src/reactive.js +255 -254
- package/src/router.js +843 -773
- package/src/ssr.js +418 -418
- package/src/store.js +318 -272
- package/src/utils.js +515 -515
- package/src/webrtc/e2ee.js +351 -0
- package/src/webrtc/errors.js +116 -0
- package/src/webrtc/ice.js +301 -0
- package/src/webrtc/index.js +131 -0
- package/src/webrtc/joinToken.js +119 -0
- package/src/webrtc/observe.js +172 -0
- package/src/webrtc/peer.js +351 -0
- package/src/webrtc/reactive.js +268 -0
- package/src/webrtc/room.js +625 -0
- package/src/webrtc/sdp.js +302 -0
- package/src/webrtc/sfu/index.js +43 -0
- package/src/webrtc/sfu/livekit.js +131 -0
- package/src/webrtc/sfu/mediasoup.js +150 -0
- package/src/webrtc/signaling.js +373 -0
- package/src/webrtc/turn.js +237 -0
- package/tests/_helpers/webrtcFakes.js +289 -0
- package/tests/audit.test.js +4158 -4158
- package/tests/cli.test.js +1136 -1023
- package/tests/compare.test.js +497 -0
- package/tests/component.test.js +3969 -3938
- package/tests/core.test.js +1910 -1910
- package/tests/dev-server.test.js +489 -0
- package/tests/diff.test.js +1416 -1416
- package/tests/docs.test.js +1664 -0
- package/tests/electron-features.test.js +864 -0
- package/tests/errors.test.js +619 -619
- package/tests/expression.test.js +1056 -1056
- package/tests/http.test.js +648 -648
- package/tests/reactive.test.js +819 -819
- package/tests/router.test.js +2327 -2327
- package/tests/ssr.test.js +870 -870
- package/tests/store.test.js +830 -830
- package/tests/test-minifier.js +153 -153
- package/tests/test-ssr.js +27 -27
- package/tests/utils.test.js +1377 -1377
- package/tests/webrtc/e2ee.test.js +283 -0
- package/tests/webrtc/ice.test.js +202 -0
- package/tests/webrtc/joinToken.test.js +89 -0
- package/tests/webrtc/observe.test.js +111 -0
- package/tests/webrtc/peer.test.js +373 -0
- package/tests/webrtc/reactive.test.js +235 -0
- package/tests/webrtc/room.test.js +406 -0
- package/tests/webrtc/sdp.test.js +151 -0
- package/tests/webrtc/sfu-livekit.test.js +119 -0
- package/tests/webrtc/sfu.test.js +160 -0
- package/tests/webrtc/signaling.test.js +251 -0
- package/tests/webrtc/turn.test.js +256 -0
- package/types/collection.d.ts +383 -383
- package/types/component.d.ts +186 -186
- package/types/errors.d.ts +135 -135
- package/types/http.d.ts +92 -92
- package/types/misc.d.ts +201 -201
- package/types/reactive.d.ts +98 -98
- package/types/router.d.ts +190 -190
- package/types/ssr.d.ts +102 -102
- package/types/store.d.ts +146 -145
- package/types/utils.d.ts +245 -245
- 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
|
+
}
|