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.
- package/AGENT_GUIDE.md +224 -0
- package/README.md +97 -16
- package/dist/addons.min.mjs +1 -1
- package/dist/addons.mjs +190 -7
- package/dist/addons.mjs.map +1 -1
- package/dist/handlers.min.mjs +1 -1
- package/dist/handlers.mjs +29 -2
- package/dist/handlers.mjs.map +1 -1
- package/dist/index.min.mjs +1 -1
- package/dist/index.mjs +4 -141
- package/dist/index.mjs.map +1 -1
- package/dist/lume.global.js +1 -1
- package/dist/lume.global.js.map +1 -1
- package/dist/shared-BGg9PbiG.mjs +249 -0
- package/dist/shared-BGg9PbiG.mjs.map +1 -0
- package/dist/shared-DmpHYKx7.mjs +15 -0
- package/dist/shared-DmpHYKx7.mjs.map +1 -0
- package/dist/shared-SUXdsYBx.mjs +233 -0
- package/dist/shared-SUXdsYBx.mjs.map +1 -0
- package/dist/state.min.mjs +1 -0
- package/dist/state.mjs +7 -0
- package/dist/state.mjs.map +1 -0
- package/llms-full.txt +6999 -0
- package/llms.txt +89 -0
- package/package.json +11 -2
- package/src/addons/index.d.ts +99 -7
- package/src/addons/index.js +13 -2
- package/src/addons/persist.js +190 -0
- package/src/addons/repeat.js +159 -10
- package/src/core/batch.js +139 -0
- package/src/core/bindDom.js +7 -3
- package/src/core/effect.js +34 -5
- package/src/core/state.js +118 -73
- package/src/handlers/index.d.ts +24 -0
- package/src/handlers/index.js +1 -0
- package/src/handlers/on.js +60 -0
- package/src/handlers/stringAttr.js +9 -2
- package/src/index.d.ts +14 -200
- package/src/index.js +3 -1
- package/src/state.d.ts +252 -0
- package/src/state.js +25 -0
- package/dist/shared-x2HJmEyO.mjs +0 -260
- package/dist/shared-x2HJmEyO.mjs.map +0 -1
package/src/addons/repeat.js
CHANGED
|
@@ -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|
|
|
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
|
-
|
|
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
|
-
//
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
429
|
-
|
|
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
|
+
}
|
package/src/core/bindDom.js
CHANGED
|
@@ -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 =>
|
|
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
|
|
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);
|
package/src/core/effect.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
145
|
-
|
|
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);
|