zero-query 0.6.3 → 0.8.6
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/README.md +39 -29
- package/cli/commands/build.js +113 -4
- package/cli/commands/bundle.js +392 -29
- package/cli/commands/create.js +1 -1
- package/cli/commands/dev/devtools/index.js +56 -0
- package/cli/commands/dev/devtools/js/components.js +49 -0
- package/cli/commands/dev/devtools/js/core.js +409 -0
- package/cli/commands/dev/devtools/js/elements.js +413 -0
- package/cli/commands/dev/devtools/js/network.js +166 -0
- package/cli/commands/dev/devtools/js/performance.js +73 -0
- package/cli/commands/dev/devtools/js/router.js +105 -0
- package/cli/commands/dev/devtools/js/source.js +132 -0
- package/cli/commands/dev/devtools/js/stats.js +35 -0
- package/cli/commands/dev/devtools/js/tabs.js +79 -0
- package/cli/commands/dev/devtools/panel.html +95 -0
- package/cli/commands/dev/devtools/styles.css +244 -0
- package/cli/commands/dev/index.js +29 -4
- package/cli/commands/dev/logger.js +6 -1
- package/cli/commands/dev/overlay.js +428 -2
- package/cli/commands/dev/server.js +42 -5
- package/cli/commands/dev/watcher.js +59 -1
- package/cli/help.js +8 -5
- package/cli/scaffold/{scripts → app}/app.js +16 -23
- package/cli/scaffold/{scripts → app}/components/about.js +4 -4
- package/cli/scaffold/{scripts → app}/components/api-demo.js +1 -1
- package/cli/scaffold/{scripts → app}/components/contacts/contacts.css +0 -7
- package/cli/scaffold/{scripts → app}/components/contacts/contacts.html +3 -3
- package/cli/scaffold/app/components/home.js +137 -0
- package/cli/scaffold/{scripts → app}/routes.js +1 -1
- package/cli/scaffold/{scripts → app}/store.js +6 -6
- package/cli/scaffold/assets/.gitkeep +0 -0
- package/cli/scaffold/{styles/styles.css → global.css} +4 -2
- package/cli/scaffold/index.html +12 -11
- package/cli/utils.js +111 -6
- package/dist/zquery.dist.zip +0 -0
- package/dist/zquery.js +1122 -158
- package/dist/zquery.min.js +3 -16
- package/index.d.ts +129 -1290
- package/index.js +15 -10
- package/package.json +7 -6
- package/src/component.js +172 -49
- package/src/core.js +359 -18
- package/src/diff.js +256 -58
- package/src/expression.js +33 -3
- package/src/reactive.js +37 -5
- package/src/router.js +243 -7
- package/tests/component.test.js +886 -0
- package/tests/core.test.js +977 -0
- package/tests/diff.test.js +525 -0
- package/tests/errors.test.js +162 -0
- package/tests/expression.test.js +482 -0
- package/tests/http.test.js +289 -0
- package/tests/reactive.test.js +339 -0
- package/tests/router.test.js +649 -0
- package/tests/store.test.js +379 -0
- package/tests/utils.test.js +512 -0
- package/types/collection.d.ts +383 -0
- package/types/component.d.ts +217 -0
- package/types/errors.d.ts +103 -0
- package/types/http.d.ts +81 -0
- package/types/misc.d.ts +179 -0
- package/types/reactive.d.ts +76 -0
- package/types/router.d.ts +161 -0
- package/types/ssr.d.ts +49 -0
- package/types/store.d.ts +107 -0
- package/types/utils.d.ts +142 -0
- package/cli/commands/dev.old.js +0 -520
- package/cli/scaffold/scripts/components/home.js +0 -137
- /package/cli/scaffold/{scripts → app}/components/contacts/contacts.js +0 -0
- /package/cli/scaffold/{scripts → app}/components/counter.js +0 -0
- /package/cli/scaffold/{scripts → app}/components/not-found.js +0 -0
- /package/cli/scaffold/{scripts → app}/components/todos.js +0 -0
package/src/diff.js
CHANGED
|
@@ -7,8 +7,27 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Approach: walk old and new trees in parallel, reconcile node by node.
|
|
9
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
|
|
10
19
|
*/
|
|
11
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
|
+
|
|
12
31
|
// ---------------------------------------------------------------------------
|
|
13
32
|
// morph(existingRoot, newHTML) — patch existing DOM to match newHTML
|
|
14
33
|
// ---------------------------------------------------------------------------
|
|
@@ -21,15 +40,53 @@
|
|
|
21
40
|
* @param {string} newHTML — The desired HTML string
|
|
22
41
|
*/
|
|
23
42
|
export function morph(rootEl, newHTML) {
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
43
|
+
const start = typeof window !== 'undefined' && window.__zqMorphHook ? performance.now() : 0;
|
|
44
|
+
const tpl = _getTemplate();
|
|
45
|
+
tpl.innerHTML = newHTML;
|
|
46
|
+
const newRoot = tpl.content;
|
|
27
47
|
|
|
28
|
-
//
|
|
48
|
+
// Move children into a wrapper for consistent handling.
|
|
49
|
+
// We move (not clone) from the template — cheaper than cloning.
|
|
29
50
|
const tempDiv = document.createElement('div');
|
|
30
51
|
while (newRoot.firstChild) tempDiv.appendChild(newRoot.firstChild);
|
|
31
52
|
|
|
32
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;
|
|
33
90
|
}
|
|
34
91
|
|
|
35
92
|
/**
|
|
@@ -39,25 +96,42 @@ export function morph(rootEl, newHTML) {
|
|
|
39
96
|
* @param {Element} newParent — desired state parent
|
|
40
97
|
*/
|
|
41
98
|
function _morphChildren(oldParent, newParent) {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
99
|
+
// Snapshot live NodeLists into arrays — childNodes is live and
|
|
100
|
+
// mutates during insertBefore/removeChild. Using a for loop to push
|
|
101
|
+
// avoids spread operator overhead for large child lists.
|
|
102
|
+
const oldCN = oldParent.childNodes;
|
|
103
|
+
const newCN = newParent.childNodes;
|
|
104
|
+
const oldLen = oldCN.length;
|
|
105
|
+
const newLen = newCN.length;
|
|
106
|
+
const oldChildren = new Array(oldLen);
|
|
107
|
+
const newChildren = new Array(newLen);
|
|
108
|
+
for (let i = 0; i < oldLen; i++) oldChildren[i] = oldCN[i];
|
|
109
|
+
for (let i = 0; i < newLen; i++) newChildren[i] = newCN[i];
|
|
110
|
+
|
|
111
|
+
// Scan for keyed elements — only build maps if keys exist
|
|
112
|
+
let hasKeys = false;
|
|
113
|
+
let oldKeyMap, newKeyMap;
|
|
114
|
+
|
|
115
|
+
for (let i = 0; i < oldLen; i++) {
|
|
116
|
+
if (_getKey(oldChildren[i]) != null) { hasKeys = true; break; }
|
|
52
117
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
118
|
+
if (!hasKeys) {
|
|
119
|
+
for (let i = 0; i < newLen; i++) {
|
|
120
|
+
if (_getKey(newChildren[i]) != null) { hasKeys = true; break; }
|
|
121
|
+
}
|
|
56
122
|
}
|
|
57
123
|
|
|
58
|
-
const hasKeys = oldKeyMap.size > 0 || newKeyMap.size > 0;
|
|
59
|
-
|
|
60
124
|
if (hasKeys) {
|
|
125
|
+
oldKeyMap = new Map();
|
|
126
|
+
newKeyMap = new Map();
|
|
127
|
+
for (let i = 0; i < oldLen; i++) {
|
|
128
|
+
const key = _getKey(oldChildren[i]);
|
|
129
|
+
if (key != null) oldKeyMap.set(key, i);
|
|
130
|
+
}
|
|
131
|
+
for (let i = 0; i < newLen; i++) {
|
|
132
|
+
const key = _getKey(newChildren[i]);
|
|
133
|
+
if (key != null) newKeyMap.set(key, i);
|
|
134
|
+
}
|
|
61
135
|
_morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, newKeyMap);
|
|
62
136
|
} else {
|
|
63
137
|
_morphChildrenUnkeyed(oldParent, oldChildren, newChildren);
|
|
@@ -68,35 +142,42 @@ function _morphChildren(oldParent, newParent) {
|
|
|
68
142
|
* Unkeyed reconciliation — positional matching.
|
|
69
143
|
*/
|
|
70
144
|
function _morphChildrenUnkeyed(oldParent, oldChildren, newChildren) {
|
|
71
|
-
const
|
|
145
|
+
const oldLen = oldChildren.length;
|
|
146
|
+
const newLen = newChildren.length;
|
|
147
|
+
const minLen = oldLen < newLen ? oldLen : newLen;
|
|
72
148
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
149
|
+
// Morph overlapping range
|
|
150
|
+
for (let i = 0; i < minLen; i++) {
|
|
151
|
+
_morphNode(oldParent, oldChildren[i], newChildren[i]);
|
|
152
|
+
}
|
|
76
153
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
154
|
+
// Append new nodes
|
|
155
|
+
if (newLen > oldLen) {
|
|
156
|
+
for (let i = oldLen; i < newLen; i++) {
|
|
157
|
+
oldParent.appendChild(newChildren[i].cloneNode(true));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Remove excess old nodes (iterate backwards to avoid index shifting)
|
|
162
|
+
if (oldLen > newLen) {
|
|
163
|
+
for (let i = oldLen - 1; i >= newLen; i--) {
|
|
164
|
+
oldParent.removeChild(oldChildren[i]);
|
|
85
165
|
}
|
|
86
166
|
}
|
|
87
167
|
}
|
|
88
168
|
|
|
89
169
|
/**
|
|
90
|
-
* Keyed reconciliation — match by z-key, reorder minimal moves
|
|
170
|
+
* Keyed reconciliation — match by z-key, reorder with minimal moves
|
|
171
|
+
* using Longest Increasing Subsequence (LIS) to find the maximum set
|
|
172
|
+
* of nodes that are already in the correct relative order, then only
|
|
173
|
+
* move the remaining nodes.
|
|
91
174
|
*/
|
|
92
175
|
function _morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, newKeyMap) {
|
|
93
|
-
// Track which old nodes are consumed
|
|
94
176
|
const consumed = new Set();
|
|
95
|
-
|
|
96
|
-
// Step 1: Build ordered list of matched old nodes for new children
|
|
97
177
|
const newLen = newChildren.length;
|
|
98
|
-
const matched = new Array(newLen);
|
|
178
|
+
const matched = new Array(newLen);
|
|
99
179
|
|
|
180
|
+
// Step 1: Match new children to old children by key
|
|
100
181
|
for (let i = 0; i < newLen; i++) {
|
|
101
182
|
const key = _getKey(newChildren[i]);
|
|
102
183
|
if (key != null && oldKeyMap.has(key)) {
|
|
@@ -108,21 +189,40 @@ function _morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, new
|
|
|
108
189
|
}
|
|
109
190
|
}
|
|
110
191
|
|
|
111
|
-
// Step 2: Remove old nodes
|
|
192
|
+
// Step 2: Remove old keyed nodes not in the new tree
|
|
112
193
|
for (let i = oldChildren.length - 1; i >= 0; i--) {
|
|
113
194
|
if (!consumed.has(i)) {
|
|
114
195
|
const key = _getKey(oldChildren[i]);
|
|
115
196
|
if (key != null && !newKeyMap.has(key)) {
|
|
116
197
|
oldParent.removeChild(oldChildren[i]);
|
|
117
|
-
} else if (key == null) {
|
|
118
|
-
// Unkeyed old node — will be handled positionally below
|
|
119
198
|
}
|
|
120
199
|
}
|
|
121
200
|
}
|
|
122
201
|
|
|
123
|
-
// Step 3:
|
|
202
|
+
// Step 3: Build index array for LIS of matched old indices.
|
|
203
|
+
// This finds the largest set of keyed nodes already in order,
|
|
204
|
+
// so we only need to move the rest — O(n log n) instead of O(n²).
|
|
205
|
+
const oldIndices = []; // Maps new-position → old-position (or -1)
|
|
206
|
+
for (let i = 0; i < newLen; i++) {
|
|
207
|
+
if (matched[i]) {
|
|
208
|
+
const key = _getKey(newChildren[i]);
|
|
209
|
+
oldIndices.push(oldKeyMap.get(key));
|
|
210
|
+
} else {
|
|
211
|
+
oldIndices.push(-1);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const lisSet = _lis(oldIndices);
|
|
216
|
+
|
|
217
|
+
// Step 4: Insert / reorder / morph — walk new children forward,
|
|
218
|
+
// using LIS to decide which nodes stay in place.
|
|
124
219
|
let cursor = oldParent.firstChild;
|
|
125
|
-
const unkeyedOld =
|
|
220
|
+
const unkeyedOld = [];
|
|
221
|
+
for (let i = 0; i < oldChildren.length; i++) {
|
|
222
|
+
if (!consumed.has(i) && _getKey(oldChildren[i]) == null) {
|
|
223
|
+
unkeyedOld.push(oldChildren[i]);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
126
226
|
let unkeyedIdx = 0;
|
|
127
227
|
|
|
128
228
|
for (let i = 0; i < newLen; i++) {
|
|
@@ -131,16 +231,14 @@ function _morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, new
|
|
|
131
231
|
let oldNode = matched[i];
|
|
132
232
|
|
|
133
233
|
if (!oldNode && newKey == null) {
|
|
134
|
-
// Try to match an unkeyed old node positionally
|
|
135
234
|
oldNode = unkeyedOld[unkeyedIdx++] || null;
|
|
136
235
|
}
|
|
137
236
|
|
|
138
237
|
if (oldNode) {
|
|
139
|
-
//
|
|
140
|
-
if (
|
|
238
|
+
// If this node is NOT part of the LIS, it needs to be moved
|
|
239
|
+
if (!lisSet.has(i)) {
|
|
141
240
|
oldParent.insertBefore(oldNode, cursor);
|
|
142
241
|
}
|
|
143
|
-
// Morph in place
|
|
144
242
|
_morphNode(oldParent, oldNode, newNode);
|
|
145
243
|
cursor = oldNode.nextSibling;
|
|
146
244
|
} else {
|
|
@@ -151,11 +249,10 @@ function _morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, new
|
|
|
151
249
|
} else {
|
|
152
250
|
oldParent.appendChild(clone);
|
|
153
251
|
}
|
|
154
|
-
// cursor stays the same — new node is before it
|
|
155
252
|
}
|
|
156
253
|
}
|
|
157
254
|
|
|
158
|
-
// Remove
|
|
255
|
+
// Remove remaining unkeyed old nodes
|
|
159
256
|
while (unkeyedIdx < unkeyedOld.length) {
|
|
160
257
|
const leftover = unkeyedOld[unkeyedIdx++];
|
|
161
258
|
if (leftover.parentNode === oldParent) {
|
|
@@ -174,6 +271,54 @@ function _morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, new
|
|
|
174
271
|
}
|
|
175
272
|
}
|
|
176
273
|
|
|
274
|
+
/**
|
|
275
|
+
* Compute the Longest Increasing Subsequence of an index array.
|
|
276
|
+
* Returns a Set of positions (in the input) that form the LIS.
|
|
277
|
+
* Entries with value -1 (unmatched) are excluded.
|
|
278
|
+
*
|
|
279
|
+
* O(n log n) — same algorithm used by Vue 3 and ivi.
|
|
280
|
+
*
|
|
281
|
+
* @param {number[]} arr — array of old-tree indices (-1 = unmatched)
|
|
282
|
+
* @returns {Set<number>} — positions in arr belonging to the LIS
|
|
283
|
+
*/
|
|
284
|
+
function _lis(arr) {
|
|
285
|
+
const len = arr.length;
|
|
286
|
+
const result = new Set();
|
|
287
|
+
if (len === 0) return result;
|
|
288
|
+
|
|
289
|
+
// tails[i] = index in arr of the smallest tail element for LIS of length i+1
|
|
290
|
+
const tails = [];
|
|
291
|
+
// prev[i] = predecessor index in arr for the LIS ending at arr[i]
|
|
292
|
+
const prev = new Array(len).fill(-1);
|
|
293
|
+
const tailIndices = []; // parallel to tails: actual positions
|
|
294
|
+
|
|
295
|
+
for (let i = 0; i < len; i++) {
|
|
296
|
+
if (arr[i] === -1) continue;
|
|
297
|
+
const val = arr[i];
|
|
298
|
+
|
|
299
|
+
// Binary search for insertion point in tails
|
|
300
|
+
let lo = 0, hi = tails.length;
|
|
301
|
+
while (lo < hi) {
|
|
302
|
+
const mid = (lo + hi) >> 1;
|
|
303
|
+
if (tails[mid] < val) lo = mid + 1;
|
|
304
|
+
else hi = mid;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
tails[lo] = val;
|
|
308
|
+
tailIndices[lo] = i;
|
|
309
|
+
prev[i] = lo > 0 ? tailIndices[lo - 1] : -1;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Reconstruct: walk backwards from the last element of LIS
|
|
313
|
+
let k = tailIndices[tails.length - 1];
|
|
314
|
+
for (let i = tails.length - 1; i >= 0; i--) {
|
|
315
|
+
result.add(k);
|
|
316
|
+
k = prev[k];
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return result;
|
|
320
|
+
}
|
|
321
|
+
|
|
177
322
|
/**
|
|
178
323
|
* Morph a single node in place.
|
|
179
324
|
*/
|
|
@@ -200,10 +345,18 @@ function _morphNode(parent, oldNode, newNode) {
|
|
|
200
345
|
|
|
201
346
|
// Both are elements — diff attributes then recurse children
|
|
202
347
|
if (oldNode.nodeType === 1) {
|
|
348
|
+
// z-skip: developer opt-out — skip diffing this subtree entirely.
|
|
349
|
+
// Useful for third-party widgets, canvas, video, or large static content.
|
|
350
|
+
if (oldNode.hasAttribute('z-skip')) return;
|
|
351
|
+
|
|
352
|
+
// Fast bail-out: if the elements are identical, skip everything.
|
|
353
|
+
// isEqualNode() is a native C++ comparison — much faster than walking
|
|
354
|
+
// attributes + children in JS when trees haven't changed.
|
|
355
|
+
if (oldNode.isEqualNode(newNode)) return;
|
|
356
|
+
|
|
203
357
|
_morphAttributes(oldNode, newNode);
|
|
204
358
|
|
|
205
359
|
// Special elements: don't recurse into their children
|
|
206
|
-
// (textarea value, input value, select, etc.)
|
|
207
360
|
const tag = oldNode.nodeName;
|
|
208
361
|
if (tag === 'INPUT') {
|
|
209
362
|
_syncInputValue(oldNode, newNode);
|
|
@@ -216,7 +369,6 @@ function _morphNode(parent, oldNode, newNode) {
|
|
|
216
369
|
return;
|
|
217
370
|
}
|
|
218
371
|
if (tag === 'SELECT') {
|
|
219
|
-
// Recurse children (options) then sync value
|
|
220
372
|
_morphChildren(oldNode, newNode);
|
|
221
373
|
if (oldNode.value !== newNode.value) {
|
|
222
374
|
oldNode.value = newNode.value;
|
|
@@ -231,23 +383,45 @@ function _morphNode(parent, oldNode, newNode) {
|
|
|
231
383
|
|
|
232
384
|
/**
|
|
233
385
|
* Sync attributes from newEl onto oldEl.
|
|
386
|
+
* Uses a single pass: build a set of new attribute names, iterate
|
|
387
|
+
* old attrs for removals, then apply new attrs.
|
|
234
388
|
*/
|
|
235
389
|
function _morphAttributes(oldEl, newEl) {
|
|
236
|
-
// Add/update attributes
|
|
237
390
|
const newAttrs = newEl.attributes;
|
|
238
|
-
|
|
391
|
+
const oldAttrs = oldEl.attributes;
|
|
392
|
+
const newLen = newAttrs.length;
|
|
393
|
+
const oldLen = oldAttrs.length;
|
|
394
|
+
|
|
395
|
+
// Fast path: if both have same number of attributes, check if they're identical
|
|
396
|
+
if (newLen === oldLen) {
|
|
397
|
+
let same = true;
|
|
398
|
+
for (let i = 0; i < newLen; i++) {
|
|
399
|
+
const na = newAttrs[i];
|
|
400
|
+
if (oldEl.getAttribute(na.name) !== na.value) { same = false; break; }
|
|
401
|
+
}
|
|
402
|
+
if (same) {
|
|
403
|
+
// Also verify no extra old attrs (names mismatch)
|
|
404
|
+
for (let i = 0; i < oldLen; i++) {
|
|
405
|
+
if (!newEl.hasAttribute(oldAttrs[i].name)) { same = false; break; }
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
if (same) return;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Build set of new attr names for O(1) lookup during removal pass
|
|
412
|
+
const newNames = new Set();
|
|
413
|
+
for (let i = 0; i < newLen; i++) {
|
|
239
414
|
const attr = newAttrs[i];
|
|
415
|
+
newNames.add(attr.name);
|
|
240
416
|
if (oldEl.getAttribute(attr.name) !== attr.value) {
|
|
241
417
|
oldEl.setAttribute(attr.name, attr.value);
|
|
242
418
|
}
|
|
243
419
|
}
|
|
244
420
|
|
|
245
421
|
// Remove stale attributes
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
if (!newEl.hasAttribute(attr.name)) {
|
|
250
|
-
oldEl.removeAttribute(attr.name);
|
|
422
|
+
for (let i = oldLen - 1; i >= 0; i--) {
|
|
423
|
+
if (!newNames.has(oldAttrs[i].name)) {
|
|
424
|
+
oldEl.removeAttribute(oldAttrs[i].name);
|
|
251
425
|
}
|
|
252
426
|
}
|
|
253
427
|
}
|
|
@@ -271,10 +445,34 @@ function _syncInputValue(oldEl, newEl) {
|
|
|
271
445
|
}
|
|
272
446
|
|
|
273
447
|
/**
|
|
274
|
-
* Get the reconciliation key from a node
|
|
448
|
+
* Get the reconciliation key from a node.
|
|
449
|
+
*
|
|
450
|
+
* Priority: z-key attribute → id attribute → data-id / data-key.
|
|
451
|
+
* Auto-detected keys use a `\0` prefix to avoid collisions with
|
|
452
|
+
* explicit z-key values.
|
|
453
|
+
*
|
|
454
|
+
* This means the LIS-optimised keyed path activates automatically
|
|
455
|
+
* whenever elements carry `id` or `data-id` / `data-key` attributes
|
|
456
|
+
* — no extra markup required.
|
|
457
|
+
*
|
|
275
458
|
* @returns {string|null}
|
|
276
459
|
*/
|
|
277
460
|
function _getKey(node) {
|
|
278
461
|
if (node.nodeType !== 1) return null;
|
|
279
|
-
|
|
462
|
+
|
|
463
|
+
// Explicit z-key — highest priority
|
|
464
|
+
const zk = node.getAttribute('z-key');
|
|
465
|
+
if (zk) return zk;
|
|
466
|
+
|
|
467
|
+
// Auto-key: id attribute (unique by spec)
|
|
468
|
+
if (node.id) return '\0id:' + node.id;
|
|
469
|
+
|
|
470
|
+
// Auto-key: data-id or data-key attributes
|
|
471
|
+
const ds = node.dataset;
|
|
472
|
+
if (ds) {
|
|
473
|
+
if (ds.id) return '\0data-id:' + ds.id;
|
|
474
|
+
if (ds.key) return '\0data-key:' + ds.key;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return null;
|
|
280
478
|
}
|
package/src/expression.js
CHANGED
|
@@ -789,13 +789,43 @@ function _evalBinary(node, scope) {
|
|
|
789
789
|
* Typical: [loopVars, state, { props, refs, $ }]
|
|
790
790
|
* @returns {*} — evaluation result, or undefined on error
|
|
791
791
|
*/
|
|
792
|
+
|
|
793
|
+
// AST cache — avoids re-tokenizing and re-parsing the same expression string.
|
|
794
|
+
// Bounded to prevent unbounded memory growth in long-lived apps.
|
|
795
|
+
const _astCache = new Map();
|
|
796
|
+
const _AST_CACHE_MAX = 512;
|
|
797
|
+
|
|
792
798
|
export function safeEval(expr, scope) {
|
|
793
799
|
try {
|
|
794
800
|
const trimmed = expr.trim();
|
|
795
801
|
if (!trimmed) return undefined;
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
802
|
+
|
|
803
|
+
// Fast path for simple identifiers: "count", "name", "visible"
|
|
804
|
+
// Avoids full tokenize→parse→evaluate overhead for the most common case.
|
|
805
|
+
if (/^[a-zA-Z_$][\w$]*$/.test(trimmed)) {
|
|
806
|
+
for (const layer of scope) {
|
|
807
|
+
if (layer && typeof layer === 'object' && trimmed in layer) {
|
|
808
|
+
return layer[trimmed];
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
// Fall through to full parser for built-in globals (Math, JSON, etc.)
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// Check AST cache
|
|
815
|
+
let ast = _astCache.get(trimmed);
|
|
816
|
+
if (!ast) {
|
|
817
|
+
const tokens = tokenize(trimmed);
|
|
818
|
+
const parser = new Parser(tokens, scope);
|
|
819
|
+
ast = parser.parse();
|
|
820
|
+
|
|
821
|
+
// Evict oldest entries when cache is full
|
|
822
|
+
if (_astCache.size >= _AST_CACHE_MAX) {
|
|
823
|
+
const first = _astCache.keys().next().value;
|
|
824
|
+
_astCache.delete(first);
|
|
825
|
+
}
|
|
826
|
+
_astCache.set(trimmed, ast);
|
|
827
|
+
}
|
|
828
|
+
|
|
799
829
|
return evaluate(ast, scope);
|
|
800
830
|
} catch (err) {
|
|
801
831
|
if (typeof console !== 'undefined' && console.debug) {
|
package/src/reactive.js
CHANGED
|
@@ -39,6 +39,8 @@ export function reactive(target, onChange, _path = '') {
|
|
|
39
39
|
const old = obj[key];
|
|
40
40
|
if (old === value) return true;
|
|
41
41
|
obj[key] = value;
|
|
42
|
+
// Invalidate proxy cache for the old value (it may have been replaced)
|
|
43
|
+
if (old && typeof old === 'object') proxyCache.delete(old);
|
|
42
44
|
try {
|
|
43
45
|
onChange(key, value, old);
|
|
44
46
|
} catch (err) {
|
|
@@ -50,6 +52,7 @@ export function reactive(target, onChange, _path = '') {
|
|
|
50
52
|
deleteProperty(obj, key) {
|
|
51
53
|
const old = obj[key];
|
|
52
54
|
delete obj[key];
|
|
55
|
+
if (old && typeof old === 'object') proxyCache.delete(old);
|
|
53
56
|
try {
|
|
54
57
|
onChange(key, undefined, old);
|
|
55
58
|
} catch (err) {
|
|
@@ -76,6 +79,10 @@ export class Signal {
|
|
|
76
79
|
// Track dependency if there's an active effect
|
|
77
80
|
if (Signal._activeEffect) {
|
|
78
81
|
this._subscribers.add(Signal._activeEffect);
|
|
82
|
+
// Record this signal in the effect's dependency set for proper cleanup
|
|
83
|
+
if (Signal._activeEffect._deps) {
|
|
84
|
+
Signal._activeEffect._deps.add(this);
|
|
85
|
+
}
|
|
79
86
|
}
|
|
80
87
|
return this._value;
|
|
81
88
|
}
|
|
@@ -89,12 +96,15 @@ export class Signal {
|
|
|
89
96
|
peek() { return this._value; }
|
|
90
97
|
|
|
91
98
|
_notify() {
|
|
92
|
-
|
|
93
|
-
|
|
99
|
+
// Snapshot subscribers before iterating — a subscriber might modify
|
|
100
|
+
// the set (e.g., an effect re-running, adding itself back)
|
|
101
|
+
const subs = [...this._subscribers];
|
|
102
|
+
for (let i = 0; i < subs.length; i++) {
|
|
103
|
+
try { subs[i](); }
|
|
94
104
|
catch (err) {
|
|
95
105
|
reportError(ErrorCode.SIGNAL_CALLBACK, 'Signal subscriber threw', { signal: this }, err);
|
|
96
106
|
}
|
|
97
|
-
}
|
|
107
|
+
}
|
|
98
108
|
}
|
|
99
109
|
|
|
100
110
|
subscribe(fn) {
|
|
@@ -129,12 +139,24 @@ export function computed(fn) {
|
|
|
129
139
|
}
|
|
130
140
|
|
|
131
141
|
/**
|
|
132
|
-
* Create a side-effect that auto-tracks signal dependencies
|
|
142
|
+
* Create a side-effect that auto-tracks signal dependencies.
|
|
143
|
+
* Returns a dispose function that removes the effect from all
|
|
144
|
+
* signals it subscribed to — prevents memory leaks.
|
|
145
|
+
*
|
|
133
146
|
* @param {Function} fn — effect function
|
|
134
147
|
* @returns {Function} — dispose function
|
|
135
148
|
*/
|
|
136
149
|
export function effect(fn) {
|
|
137
150
|
const execute = () => {
|
|
151
|
+
// Clean up old subscriptions before re-running so stale
|
|
152
|
+
// dependencies from a previous run are properly removed
|
|
153
|
+
if (execute._deps) {
|
|
154
|
+
for (const sig of execute._deps) {
|
|
155
|
+
sig._subscribers.delete(execute);
|
|
156
|
+
}
|
|
157
|
+
execute._deps.clear();
|
|
158
|
+
}
|
|
159
|
+
|
|
138
160
|
Signal._activeEffect = execute;
|
|
139
161
|
try { fn(); }
|
|
140
162
|
catch (err) {
|
|
@@ -142,9 +164,19 @@ export function effect(fn) {
|
|
|
142
164
|
}
|
|
143
165
|
finally { Signal._activeEffect = null; }
|
|
144
166
|
};
|
|
167
|
+
|
|
168
|
+
// Track which signals this effect reads from
|
|
169
|
+
execute._deps = new Set();
|
|
170
|
+
|
|
145
171
|
execute();
|
|
146
172
|
return () => {
|
|
147
|
-
//
|
|
173
|
+
// Dispose: remove this effect from every signal it subscribed to
|
|
174
|
+
if (execute._deps) {
|
|
175
|
+
for (const sig of execute._deps) {
|
|
176
|
+
sig._subscribers.delete(execute);
|
|
177
|
+
}
|
|
178
|
+
execute._deps.clear();
|
|
179
|
+
}
|
|
148
180
|
Signal._activeEffect = null;
|
|
149
181
|
};
|
|
150
182
|
}
|