zero-query 0.7.5 → 0.8.7

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 (65) hide show
  1. package/README.md +39 -30
  2. package/cli/commands/build.js +110 -1
  3. package/cli/commands/bundle.js +127 -50
  4. package/cli/commands/create.js +1 -1
  5. package/cli/commands/dev/devtools/index.js +56 -0
  6. package/cli/commands/dev/devtools/js/components.js +49 -0
  7. package/cli/commands/dev/devtools/js/core.js +409 -0
  8. package/cli/commands/dev/devtools/js/elements.js +413 -0
  9. package/cli/commands/dev/devtools/js/network.js +166 -0
  10. package/cli/commands/dev/devtools/js/performance.js +73 -0
  11. package/cli/commands/dev/devtools/js/router.js +105 -0
  12. package/cli/commands/dev/devtools/js/source.js +132 -0
  13. package/cli/commands/dev/devtools/js/stats.js +35 -0
  14. package/cli/commands/dev/devtools/js/tabs.js +79 -0
  15. package/cli/commands/dev/devtools/panel.html +95 -0
  16. package/cli/commands/dev/devtools/styles.css +244 -0
  17. package/cli/commands/dev/index.js +28 -3
  18. package/cli/commands/dev/logger.js +6 -1
  19. package/cli/commands/dev/overlay.js +377 -0
  20. package/cli/commands/dev/server.js +8 -0
  21. package/cli/commands/dev/watcher.js +26 -1
  22. package/cli/help.js +8 -5
  23. package/cli/scaffold/{scripts → app}/app.js +1 -1
  24. package/cli/scaffold/{scripts → app}/components/about.js +4 -4
  25. package/cli/scaffold/{scripts → app}/components/api-demo.js +1 -1
  26. package/cli/scaffold/app/components/home.js +137 -0
  27. package/cli/scaffold/{scripts → app}/routes.js +1 -1
  28. package/cli/scaffold/{scripts → app}/store.js +6 -6
  29. package/cli/scaffold/assets/.gitkeep +0 -0
  30. package/cli/scaffold/{styles/styles.css → global.css} +3 -2
  31. package/cli/scaffold/index.html +11 -11
  32. package/dist/zquery.dist.zip +0 -0
  33. package/dist/zquery.js +740 -226
  34. package/dist/zquery.min.js +2 -2
  35. package/index.d.ts +11 -11
  36. package/index.js +15 -10
  37. package/package.json +3 -2
  38. package/src/component.js +154 -139
  39. package/src/core.js +57 -11
  40. package/src/diff.js +256 -58
  41. package/src/expression.js +33 -3
  42. package/src/reactive.js +37 -5
  43. package/src/router.js +196 -7
  44. package/src/ssr.js +1 -1
  45. package/tests/component.test.js +582 -0
  46. package/tests/core.test.js +251 -0
  47. package/tests/diff.test.js +333 -2
  48. package/tests/expression.test.js +148 -0
  49. package/tests/http.test.js +108 -0
  50. package/tests/reactive.test.js +148 -0
  51. package/tests/router.test.js +317 -0
  52. package/tests/store.test.js +126 -0
  53. package/tests/utils.test.js +161 -2
  54. package/types/collection.d.ts +17 -2
  55. package/types/component.d.ts +10 -34
  56. package/types/misc.d.ts +13 -0
  57. package/types/router.d.ts +30 -1
  58. package/cli/commands/dev.old.js +0 -520
  59. package/cli/scaffold/scripts/components/home.js +0 -137
  60. /package/cli/scaffold/{scripts → app}/components/contacts/contacts.css +0 -0
  61. /package/cli/scaffold/{scripts → app}/components/contacts/contacts.html +0 -0
  62. /package/cli/scaffold/{scripts → app}/components/contacts/contacts.js +0 -0
  63. /package/cli/scaffold/{scripts → app}/components/counter.js +0 -0
  64. /package/cli/scaffold/{scripts → app}/components/not-found.js +0 -0
  65. /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 template = document.createElement('template');
25
- template.innerHTML = newHTML;
26
- const newRoot = template.content;
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
- // Convert to element for consistent handling — wrap in a div if needed
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
- const oldChildren = [...oldParent.childNodes];
43
- const newChildren = [...newParent.childNodes];
44
-
45
- // Build key maps for keyed element matching
46
- const oldKeyMap = new Map();
47
- const newKeyMap = new Map();
48
-
49
- for (let i = 0; i < oldChildren.length; i++) {
50
- const key = _getKey(oldChildren[i]);
51
- if (key != null) oldKeyMap.set(key, i);
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
- for (let i = 0; i < newChildren.length; i++) {
54
- const key = _getKey(newChildren[i]);
55
- if (key != null) newKeyMap.set(key, i);
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 maxLen = Math.max(oldChildren.length, newChildren.length);
145
+ const oldLen = oldChildren.length;
146
+ const newLen = newChildren.length;
147
+ const minLen = oldLen < newLen ? oldLen : newLen;
72
148
 
73
- for (let i = 0; i < maxLen; i++) {
74
- const oldNode = oldChildren[i];
75
- const newNode = newChildren[i];
149
+ // Morph overlapping range
150
+ for (let i = 0; i < minLen; i++) {
151
+ _morphNode(oldParent, oldChildren[i], newChildren[i]);
152
+ }
76
153
 
77
- if (!oldNode && newNode) {
78
- // New node append
79
- oldParent.appendChild(newNode.cloneNode(true));
80
- } else if (oldNode && !newNode) {
81
- // Extra old node — remove
82
- oldParent.removeChild(oldNode);
83
- } else if (oldNode && newNode) {
84
- _morphNode(oldParent, oldNode, newNode);
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); // matched[newIdx] = oldNode | null
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 that are not in the new tree
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: Insert/reorder/morph
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 = oldChildren.filter((n, i) => !consumed.has(i) && _getKey(n) == null);
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
- // Move into position if needed
140
- if (oldNode !== cursor) {
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 any remaining unkeyed old nodes at the end
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
- for (let i = 0; i < newAttrs.length; i++) {
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
- const oldAttrs = oldEl.attributes;
247
- for (let i = oldAttrs.length - 1; i >= 0; i--) {
248
- const attr = oldAttrs[i];
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 (z-key attribute).
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
- return node.getAttribute('z-key') || null;
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
- const tokens = tokenize(trimmed);
797
- const parser = new Parser(tokens, scope);
798
- const ast = parser.parse();
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
- this._subscribers.forEach(fn => {
93
- try { fn(); }
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
- // Remove this effect from all signals that track it
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
  }