lume-js 2.2.1 → 2.3.1

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 (43) hide show
  1. package/AGENT_GUIDE.md +224 -0
  2. package/README.md +97 -16
  3. package/dist/addons.min.mjs +1 -1
  4. package/dist/addons.mjs +190 -7
  5. package/dist/addons.mjs.map +1 -1
  6. package/dist/handlers.min.mjs +1 -1
  7. package/dist/handlers.mjs +29 -2
  8. package/dist/handlers.mjs.map +1 -1
  9. package/dist/index.min.mjs +1 -1
  10. package/dist/index.mjs +4 -141
  11. package/dist/index.mjs.map +1 -1
  12. package/dist/lume.global.js +1 -1
  13. package/dist/lume.global.js.map +1 -1
  14. package/dist/shared-BGg9PbiG.mjs +249 -0
  15. package/dist/shared-BGg9PbiG.mjs.map +1 -0
  16. package/dist/shared-DmpHYKx7.mjs +15 -0
  17. package/dist/shared-DmpHYKx7.mjs.map +1 -0
  18. package/dist/shared-SUXdsYBx.mjs +233 -0
  19. package/dist/shared-SUXdsYBx.mjs.map +1 -0
  20. package/dist/state.min.mjs +1 -0
  21. package/dist/state.mjs +7 -0
  22. package/dist/state.mjs.map +1 -0
  23. package/llms-full.txt +6999 -0
  24. package/llms.txt +89 -0
  25. package/package.json +11 -2
  26. package/src/addons/index.d.ts +99 -7
  27. package/src/addons/index.js +13 -2
  28. package/src/addons/persist.js +190 -0
  29. package/src/addons/repeat.js +159 -10
  30. package/src/core/batch.js +139 -0
  31. package/src/core/bindDom.js +7 -3
  32. package/src/core/effect.js +34 -5
  33. package/src/core/state.js +118 -73
  34. package/src/handlers/index.d.ts +24 -0
  35. package/src/handlers/index.js +1 -0
  36. package/src/handlers/on.js +60 -0
  37. package/src/handlers/stringAttr.js +9 -2
  38. package/src/index.d.ts +14 -200
  39. package/src/index.js +3 -1
  40. package/src/state.d.ts +252 -0
  41. package/src/state.js +25 -0
  42. package/dist/shared-x2HJmEyO.mjs +0 -260
  43. package/dist/shared-x2HJmEyO.mjs.map +0 -1
@@ -22,6 +22,38 @@
22
22
  * store.items = [...items] // ✅ Triggers update
23
23
  *
24
24
  * ═══════════════════════════════════════════════════════════════════════
25
+ * PATTERN 0: Template-based (recommended) — declarative, zero DOM code
26
+ * ═══════════════════════════════════════════════════════════════════════
27
+ *
28
+ * HTML — a standard <template> element, valid HTML, no custom syntax:
29
+ *
30
+ * <ul id="list">
31
+ * <template>
32
+ * <li>
33
+ * <strong data-bind="name"></strong>
34
+ * <span data-bind="role"></span>
35
+ * <em data-bind="$index"></em>
36
+ * </li>
37
+ * </template>
38
+ * </ul>
39
+ *
40
+ * JS:
41
+ *
42
+ * repeat('#list', store, 'people', {
43
+ * key: p => p.id,
44
+ * template: true // use the <template> inside the container
45
+ * });
46
+ *
47
+ * data-bind paths resolve against EACH ITEM: "name" → item.name,
48
+ * "user.city" → item.user.city, "$item" → the item itself (primitive
49
+ * arrays), "$index" → current index. Inputs get .value/.checked, other
50
+ * elements get textContent — identical semantics to bindDom's data-bind.
51
+ * These are one-way snapshot bindings re-applied per list update (items
52
+ * are plain objects, not stores). Combine with create/update for event
53
+ * listeners or extra binding. template also accepts a CSS selector or an
54
+ * HTMLTemplateElement.
55
+ *
56
+ * ═══════════════════════════════════════════════════════════════════════
25
57
  * PATTERN 1: Simple (render only) - for simple cases or backward compat
26
58
  * ═══════════════════════════════════════════════════════════════════════
27
59
  *
@@ -79,6 +111,79 @@
79
111
  * });
80
112
  */
81
113
  import { logWarn, logError } from '../utils/log.js';
114
+ import { applyBindValue } from '../core/bindDom.js';
115
+
116
+ /**
117
+ * Resolve the template option to the <template> element.
118
+ * Accepts true (first <template> inside the container), a CSS selector,
119
+ * or an HTMLTemplateElement directly.
120
+ */
121
+ function resolveTemplateEl(template, containerEl) {
122
+ let templateEl = template;
123
+ if (template === true) {
124
+ templateEl = containerEl.querySelector('template');
125
+ } else if (typeof template === 'string') {
126
+ templateEl = document.querySelector(template);
127
+ }
128
+ if (!templateEl || templateEl.tagName !== 'TEMPLATE') {
129
+ throw new Error('[Lume.js] repeat(): template not found or not a <template> element');
130
+ }
131
+ if (templateEl.content.children.length !== 1) {
132
+ throw new Error('[Lume.js] repeat(): template must contain exactly one root element');
133
+ }
134
+ return templateEl;
135
+ }
136
+
137
+ /**
138
+ * Resolve the template option to its clone root, plus the container child
139
+ * that is (or contains) the source <template>. Reconciliation and cleanup
140
+ * must leave that child alone — it's the user's markup, and removing it
141
+ * would break re-binding with `template: true` after cleanup. keepEl is
142
+ * null when there is no template or it lives outside the container.
143
+ */
144
+ function resolveTemplate(template, containerEl) {
145
+ if (!template) return { templateRoot: null, keepEl: null };
146
+ const templateEl = resolveTemplateEl(template, containerEl);
147
+ let keepEl = templateEl;
148
+ while (keepEl && keepEl.parentNode !== containerEl) keepEl = keepEl.parentNode;
149
+ return { templateRoot: templateEl.content.firstElementChild, keepEl };
150
+ }
151
+
152
+ /**
153
+ * Collect [data-bind] nodes of a cloned item element into a compiled
154
+ * binding list. Paths are resolved against the ITEM (not the store):
155
+ * data-bind="name" → item.name
156
+ * data-bind="user.city" → item.user.city
157
+ * data-bind="$item" → the item itself (for primitive arrays)
158
+ * data-bind="$index" → the item's current index
159
+ */
160
+ function collectItemBindings(el) {
161
+ const bindings = [];
162
+ const add = (node) => {
163
+ const path = node.getAttribute('data-bind');
164
+ bindings.push({ node, path, keys: path === '$item' || path === '$index' ? null : path.split('.') });
165
+ };
166
+ if (el.hasAttribute('data-bind')) add(el);
167
+ for (const node of el.querySelectorAll('[data-bind]')) add(node);
168
+ return bindings;
169
+ }
170
+
171
+ function applyItemBindings(bindings, item, index) {
172
+ for (const b of bindings) {
173
+ let val;
174
+ if (b.path === '$index') {
175
+ val = index;
176
+ } else if (b.path === '$item') {
177
+ val = item;
178
+ } else {
179
+ val = item;
180
+ for (let i = 0; i < b.keys.length && val != null; i++) {
181
+ val = val[b.keys[i]];
182
+ }
183
+ }
184
+ applyBindValue(b.node, val);
185
+ }
186
+ }
82
187
 
83
188
  /**
84
189
  * Default focus preservation strategy
@@ -173,7 +278,8 @@ export function defaultScrollPreservation(container, context = {}) {
173
278
  * @param {Function} [options.create] - Function for new elements only: (item, element, index) => void | Function. If a function is returned, it is registered as the element's cleanup and called automatically when the element is removed (by list update or full cleanup).
174
279
  * @param {Function} [options.update] - Function for data binding: (item, element, index, { isFirstRender }) => void. Skipped if same item reference AND same index.
175
280
  * @param {Function} [options.remove] - Additional cleanup when element is removed: (item, element) => void. Called after any cleanup function returned by create(). Optional — prefer returning a cleanup from create() for automatic lifecycle management.
176
- * @param {string|Function} [options.element='div'] - Element tag name or factory function
281
+ * @param {true|string|HTMLTemplateElement} [options.template] - Declarative item structure from a <template> element: true = first <template> inside the container, string = CSS selector, or the element itself. The template must have exactly one root element; it is cloned per item and its [data-bind] paths are bound to the item on every update ("name", "user.city", "$item", "$index"). When set, options.element is ignored and options.render is ignored (with a warning); create/update remain available on top.
282
+ * @param {string|Function} [options.element='div'] - Element tag name or factory function (ignored when options.template is set)
177
283
  * @param {Function|null} [options.preserveFocus=defaultFocusPreservation] - Focus preservation strategy (null to disable)
178
284
  * @param {Function|null} [options.preserveScroll=defaultScrollPreservation] - Scroll preservation strategy (null to disable)
179
285
  * @returns {Function} Cleanup function
@@ -186,6 +292,7 @@ export function repeat(container, store, arrayKey, options) {
186
292
  create,
187
293
  update,
188
294
  remove,
295
+ template = null,
189
296
  element = 'div',
190
297
  preserveFocus = defaultFocusPreservation,
191
298
  preserveScroll = defaultScrollPreservation
@@ -206,7 +313,15 @@ export function repeat(container, store, arrayKey, options) {
206
313
  throw new Error('[Lume.js] repeat(): options.key must be a function');
207
314
  }
208
315
 
209
- if (typeof render !== 'function' && typeof create !== 'function') {
316
+ // Template mode: structure and data binding come from the <template>;
317
+ // render/create/update are all optional on top of it.
318
+ const { templateRoot, keepEl } = resolveTemplate(template, containerEl);
319
+
320
+ if (templateRoot && typeof render === 'function') {
321
+ logWarn('[Lume.js] repeat(): options.render is ignored when options.template is set — use create/update instead');
322
+ }
323
+
324
+ if (!templateRoot && typeof render !== 'function' && typeof create !== 'function') {
210
325
  throw new Error('[Lume.js] repeat(): options.render or options.create must be a function');
211
326
  }
212
327
 
@@ -218,18 +333,40 @@ export function repeat(container, store, arrayKey, options) {
218
333
  const prevIndexByKey = new Map();
219
334
  // key -> cleanup function returned by create()
220
335
  const cleanupByKey = new Map();
336
+ // key -> compiled [data-bind] nodes of the clone (template mode only)
337
+ const bindingsByKey = new Map();
221
338
  const seenKeys = new Set();
222
339
 
223
340
  function createElement() {
341
+ if (templateRoot) return templateRoot.cloneNode(true);
224
342
  return typeof element === 'function'
225
343
  ? element()
226
344
  : document.createElement(element);
227
345
  }
228
346
 
347
+ /**
348
+ * Empty the container, preserving the in-container source template.
349
+ * Manual removal (not replaceChildren) for two reasons: replaceChildren
350
+ * is Chrome 86+/Safari 14+ — above the documented ES2020-era browser
351
+ * floor — and re-inserting keepEl would re-parent a template the user
352
+ * has since moved (or resurrect one they deleted).
353
+ */
354
+ function clearContainer() {
355
+ let node = containerEl.firstChild;
356
+ while (node) {
357
+ const next = node.nextSibling;
358
+ if (node !== keepEl) containerEl.removeChild(node);
359
+ node = next;
360
+ }
361
+ }
362
+
229
363
  function reconcileDOM(container, nextEls) {
230
364
  let ptr = container.firstChild;
231
365
 
232
366
  for (let i = 0; i < nextEls.length; i++) {
367
+ // Never treat the source template (or its wrapper) as a list row
368
+ if (keepEl && ptr === keepEl) ptr = ptr.nextSibling;
369
+
233
370
  const desired = nextEls[i];
234
371
 
235
372
  if (ptr === desired) {
@@ -240,10 +377,10 @@ export function repeat(container, store, arrayKey, options) {
240
377
  container.insertBefore(desired, ptr);
241
378
  }
242
379
 
243
- // Remove leftover children not in nextEls
380
+ // Remove leftover children not in nextEls (preserving the template)
244
381
  while (ptr) {
245
382
  const next = ptr.nextSibling;
246
- container.removeChild(ptr);
383
+ if (ptr !== keepEl) container.removeChild(ptr);
247
384
  ptr = next;
248
385
  }
249
386
  }
@@ -298,10 +435,13 @@ export function repeat(container, store, arrayKey, options) {
298
435
  if (isFirstRender) {
299
436
  el = createElement();
300
437
  elementsByKey.set(k, el);
438
+ if (templateRoot) {
439
+ bindingsByKey.set(k, collectItemBindings(el));
440
+ }
301
441
  }
302
442
 
303
443
  try {
304
- // Call create for new elements (DOM structure)
444
+ // Call create for new elements (DOM structure / event listeners)
305
445
  if (isFirstRender && create) {
306
446
  const cleanup = create(item, el, i);
307
447
  if (typeof cleanup === 'function') {
@@ -309,11 +449,17 @@ export function repeat(container, store, arrayKey, options) {
309
449
  }
310
450
  }
311
451
 
312
- // Call update for data binding (new and existing elements)
452
+ // Data binding (new and existing elements)
313
453
  // Skip if same item reference AND same index (optimization)
314
454
  const prevItem = prevItemsByKey.get(k);
315
455
  const prevIndex = prevIndexByKey.get(k);
316
- if (update) {
456
+ if (templateRoot) {
457
+ if (prevItem !== item || prevIndex !== i) {
458
+ applyItemBindings(bindingsByKey.get(k), item, i);
459
+ // update is optional extra binding on top of the template
460
+ if (update) update(item, el, i, { isFirstRender });
461
+ }
462
+ } else if (update) {
317
463
  if (prevItem !== item || prevIndex !== i) {
318
464
  update(item, el, i, { isFirstRender });
319
465
  }
@@ -359,6 +505,7 @@ export function repeat(container, store, arrayKey, options) {
359
505
  prevItemsByKey.delete(k);
360
506
  prevIndexByKey.delete(k);
361
507
  cleanupByKey.delete(k);
508
+ bindingsByKey.delete(k);
362
509
  }
363
510
  }
364
511
  }
@@ -397,11 +544,12 @@ export function repeat(container, store, arrayKey, options) {
397
544
  remove(prevItem, el);
398
545
  }
399
546
  }
400
- containerEl.replaceChildren();
547
+ clearContainer();
401
548
  elementsByKey.clear();
402
549
  prevItemsByKey.clear();
403
550
  prevIndexByKey.clear();
404
551
  cleanupByKey.clear();
552
+ bindingsByKey.clear();
405
553
  seenKeys.clear();
406
554
  };
407
555
  }
@@ -425,12 +573,13 @@ export function repeat(container, store, arrayKey, options) {
425
573
  remove(prevItem, el);
426
574
  }
427
575
  }
428
- // Clear DOM elements (replaceChildren is faster than loop)
429
- containerEl.replaceChildren();
576
+ // Clear DOM elements (preserving the source template, if any)
577
+ clearContainer();
430
578
  elementsByKey.clear();
431
579
  prevItemsByKey.clear();
432
580
  prevIndexByKey.clear();
433
581
  cleanupByKey.clear();
582
+ bindingsByKey.clear();
434
583
  seenKeys.clear();
435
584
  };
436
585
  }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Lume-JS Cross-Store Batching
3
+ *
4
+ * While batchDepth > 0, states skip their microtask flush and enqueue a
5
+ * small flush handle here instead (via enqueueIfBatching, called from
6
+ * state.js's scheduler); batch() drains the set synchronously when the
7
+ * outermost batch ends. Effects collected from all enqueued states run
8
+ * from one Set per wave, so an effect depending on several mutated stores
9
+ * runs exactly once per batch instead of once per store.
10
+ *
11
+ * This module never imports state.js — state.js imports from here — so
12
+ * there is no cycle, no global scheduler object, and no import side effect.
13
+ */
14
+
15
+ import { logError, logWarn } from '../utils/log.js';
16
+
17
+ // Cap for cascading flush waves (effects mutating state that re-triggers
18
+ // effects). Shared with the per-state microtask flush in state.js.
19
+ export const MAX_FLUSH_ITERATIONS = 100;
20
+
21
+ let batchDepth = 0;
22
+ const batchedStates = new Set();
23
+
24
+ /**
25
+ * Called by state.js when a write is scheduled. Returns true if a batch is
26
+ * active and the state's flush handle was captured (the caller must then
27
+ * skip its own microtask scheduling).
28
+ *
29
+ * Internal API between core modules — not exported from the package root.
30
+ *
31
+ * @param {{runBeforeFlushHooks: function, notifySubscribers: function, takeEffects: function}} handle
32
+ * @returns {boolean}
33
+ */
34
+ export function enqueueIfBatching(handle) {
35
+ if (batchDepth === 0) return false;
36
+ batchedStates.add(handle);
37
+ return true;
38
+ }
39
+
40
+ function flushBatchedStates() {
41
+ let iterations = 0;
42
+ while (batchedStates.size > 0 && iterations < MAX_FLUSH_ITERATIONS) {
43
+ iterations++;
44
+ const wave = Array.from(batchedStates);
45
+ batchedStates.clear();
46
+
47
+ // Notify each state's subscribers, collecting effects into one
48
+ // deduplicated set (the same effect queued by N stores runs once).
49
+ const effects = new Set();
50
+ for (const s of wave) {
51
+ s.runBeforeFlushHooks();
52
+ s.notifySubscribers();
53
+ for (const fx of s.takeEffects()) effects.add(fx);
54
+ }
55
+
56
+ // Effects run after all subscribers of the wave. Writes they make
57
+ // re-enter batchedStates (depth is still held) → next iteration.
58
+ for (const fx of effects) {
59
+ try { fx(); }
60
+ catch (err) { logError('[Lume.js state] Error in effect:', err); }
61
+ }
62
+ }
63
+ if (iterations >= MAX_FLUSH_ITERATIONS) {
64
+ // Drop the runaway wave so a future, unrelated batch doesn't inherit
65
+ // it. Nothing is lost permanently: the states keep their queued work
66
+ // and flush it on their next write via the normal microtask path.
67
+ batchedStates.clear();
68
+ logError(
69
+ '[Lume.js state] Maximum batch flush iterations reached (100). ' +
70
+ 'This usually indicates an infinite loop caused by an effect or computed mutating state it depends on.'
71
+ );
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Group multiple state writes and flush them together, synchronously,
77
+ * when the outermost batch() returns.
78
+ *
79
+ * Guarantees:
80
+ * - Subscribers see only the final value of each key (intermediate writes
81
+ * within the batch are coalesced, as with microtask batching).
82
+ * - An effect that depends on several stores mutated in the batch runs
83
+ * exactly ONCE — unlike microtask batching, which is per-state and runs
84
+ * such effects once per store.
85
+ * - Nested batch() calls are absorbed: everything flushes when the
86
+ * outermost batch ends.
87
+ * - If fn throws, writes made before the throw still flush, then the
88
+ * error propagates. State scheduling is left clean either way.
89
+ *
90
+ * `fn` must be synchronous — writes after an `await` happen outside the
91
+ * batch and fall back to normal per-state microtask flushing (a console
92
+ * warning is logged if fn returns a Promise).
93
+ *
94
+ * @param {function} fn - Function performing state writes
95
+ * @returns {*} The return value of fn
96
+ *
97
+ * @example
98
+ * import { state, effect, batch } from 'lume-js';
99
+ *
100
+ * const a = state({ value: 1 });
101
+ * const b = state({ value: 2 });
102
+ * effect(() => render(a.value + b.value));
103
+ *
104
+ * batch(() => {
105
+ * a.value = 10;
106
+ * b.value = 20;
107
+ * }); // render() ran exactly once, seeing 30
108
+ */
109
+ export function batch(fn) {
110
+ if (typeof fn !== 'function') {
111
+ throw new Error('batch() requires a function');
112
+ }
113
+
114
+ // Nested batch: let the outermost batch flush everything
115
+ if (batchDepth > 0) return fn();
116
+
117
+ batchDepth++;
118
+ let result;
119
+ try {
120
+ result = fn();
121
+ if (result && typeof result.then === 'function') {
122
+ logWarn(
123
+ '[Lume.js batch] batch() received an async function. Only writes before the first await are batched; ' +
124
+ 'later writes flush via normal microtasks.'
125
+ );
126
+ }
127
+ return result;
128
+ } finally {
129
+ // Flush while depth is still held so cascading writes from
130
+ // subscribers/effects keep collecting into batchedStates (deduped),
131
+ // then release. Runs on success AND when fn throws (writes made
132
+ // before the throw are committed, then the error propagates).
133
+ try {
134
+ flushBatchedStates();
135
+ } finally {
136
+ batchDepth--;
137
+ }
138
+ }
139
+ }
@@ -146,7 +146,7 @@ function handleDataBind(el, store, path, bindingMap) {
146
146
  if (!result) return null;
147
147
 
148
148
  const { target, key } = result;
149
- const unsub = target.$subscribe(key, val => updateElement(el, val));
149
+ const unsub = target.$subscribe(key, val => applyBindValue(el, val));
150
150
 
151
151
  if (isFormInput(el)) {
152
152
  bindingMap.set(el, { target, key });
@@ -205,9 +205,13 @@ function resolveProp(store, path) {
205
205
  }
206
206
 
207
207
  /**
208
- * Update element with value (for data-bind)
208
+ * Update element with value (for data-bind).
209
+ *
210
+ * Exported for internal reuse (e.g. the repeat addon's template bindings)
211
+ * so addon and core data-bind semantics never drift. Not part of the
212
+ * public package API.
209
213
  */
210
- function updateElement(el, val) {
214
+ export function applyBindValue(el, val) {
211
215
  if (el.tagName === "INPUT") {
212
216
  if (el.type === "checkbox") el.checked = Boolean(val);
213
217
  else if (el.type === "radio") el.checked = el.value === String(val);
@@ -29,6 +29,8 @@ import { logError } from '../utils/log.js';
29
29
  * Features:
30
30
  * - Automatic dependency collection via withReadObserver scope (default)
31
31
  * - Explicit dependencies for side-effects
32
+ * - Explicit-deps notifications are coalesced: one run per microtask,
33
+ * no matter how many tracked keys (or stores) changed in the same tick
32
34
  * - Returns cleanup function
33
35
  * - Compatible with per-state batching
34
36
  */
@@ -87,6 +89,27 @@ export function effect(fn, deps) {
87
89
 
88
90
  // EXPLICIT DEPS MODE: deps array provided
89
91
  if (Array.isArray(deps)) {
92
+ // Coalesce notifications: when several tracked keys change in the same
93
+ // flush (or several stores flush in the same tick), run the effect once
94
+ // per microtask instead of once per changed key. This matches the
95
+ // dedupe guarantee auto-tracking mode gets from the per-state effect queue.
96
+ let scheduled = false;
97
+ let disposed = false;
98
+ cleanups.push(() => { disposed = true; });
99
+
100
+ const scheduleExecute = () => {
101
+ if (scheduled) return;
102
+ scheduled = true;
103
+ queueMicrotask(() => {
104
+ scheduled = false;
105
+ if (disposed) return;
106
+ // execute() logs and re-throws; swallow here so a throwing effect
107
+ // doesn't become an uncaught error inside the microtask (same
108
+ // containment the state flush loop provides for subscribers).
109
+ try { execute(); } catch { /* already logged by execute() */ }
110
+ });
111
+ };
112
+
90
113
  // Subscribe to each [store, key1, key2, ...] tuple explicitly
91
114
  for (const dep of deps) {
92
115
  if (Array.isArray(dep) && dep.length >= 2) {
@@ -102,7 +125,7 @@ export function effect(fn, deps) {
102
125
  isFirst = false;
103
126
  return; // Skip first call, we'll run execute() below
104
127
  }
105
- execute();
128
+ scheduleExecute();
106
129
  });
107
130
  cleanups.push(unsub);
108
131
  }
@@ -123,12 +146,13 @@ export function effect(fn, deps) {
123
146
  // them so the effect stays reactive.
124
147
  const oldCleanups = cleanups.splice(0);
125
148
 
126
- // Create effect context for tracking
149
+ // Tracking is keyed per store proxy (WeakMap<proxy, Set<key>>) so the
150
+ // same key name on two different stores creates two subscriptions.
127
151
  const myContext = {
128
152
  fn,
129
153
  cleanups,
130
154
  execute: executeWithTracking,
131
- tracking: {}
155
+ tracking: new WeakMap()
132
156
  };
133
157
 
134
158
  // Set as current effect (for state.js to detect)
@@ -141,8 +165,13 @@ export function effect(fn, deps) {
141
165
  const onRead = (proxy, key, registerEffect) => {
142
166
  // Only the currently active effect (not a nested one) creates subscriptions
143
167
  if (currentEffect !== myContext) return;
144
- if (myContext.tracking[key]) return;
145
- myContext.tracking[key] = true;
168
+ let keys = myContext.tracking.get(proxy);
169
+ if (!keys) {
170
+ keys = new Set();
171
+ myContext.tracking.set(proxy, keys);
172
+ }
173
+ if (keys.has(key)) return;
174
+ keys.add(key);
146
175
  myContext.cleanups.push(registerEffect(key, myContext.execute));
147
176
  };
148
177
  withReadObserver(onRead, fn);