lume-js 2.2.1 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -163,7 +163,25 @@ export interface RepeatOptions<T> {
163
163
  */
164
164
  update?: (item: T, element: HTMLElement, index: number, context: UpdateContext) => void;
165
165
 
166
- /** Element tag name or factory function (default: 'div') */
166
+ /**
167
+ * Declarative item structure from a standard <template> element.
168
+ * - true: use the first <template> inside the container
169
+ * - string: CSS selector resolving to a <template>
170
+ * - HTMLTemplateElement: use directly
171
+ *
172
+ * The template must contain exactly one root element. It is cloned per
173
+ * item, and its [data-bind] paths are bound against the ITEM on every
174
+ * update: "name" → item.name, "user.city" → item.user.city,
175
+ * "$item" → the item itself, "$index" → current index.
176
+ * Inputs receive .value/.checked; other elements receive textContent
177
+ * (same semantics as bindDom's data-bind). One-way snapshot bindings.
178
+ *
179
+ * When set: options.element is ignored, options.render is ignored (with
180
+ * a console warning); create/update still run on top of the bindings.
181
+ */
182
+ template?: true | string | HTMLTemplateElement;
183
+
184
+ /** Element tag name or factory function (default: 'div'; ignored when template is set) */
167
185
  element?: string | (() => HTMLElement);
168
186
 
169
187
  /**
@@ -396,7 +414,8 @@ export const debug: Debug;
396
414
 
397
415
  /**
398
416
  * Returns true if the value is a Lume reactive proxy created by state().
399
- * Uses duck-typing: checks for the presence of $subscribe.
417
+ * Checks the shared reactive brand symbol first (Symbol.for('lume.reactive')),
418
+ * then falls back to duck-typing ($subscribe) for older stores.
400
419
  * This is a type guard that narrows the type to ReactiveState.
401
420
  *
402
421
  * @param obj - Value to check
@@ -501,3 +520,52 @@ export function createCleanupGroup(): CleanupGroup;
501
520
  */
502
521
  export function hydrateState(selector?: string): object;
503
522
 
523
+ /**
524
+ * Options for persist()
525
+ */
526
+ export interface PersistOptions {
527
+ /**
528
+ * Keys to persist. Default: all own non-$ keys of the store at call time.
529
+ */
530
+ keys?: string[];
531
+ /**
532
+ * Storage object (localStorage, sessionStorage, or any Storage-like
533
+ * object with getItem/setItem). Default: localStorage.
534
+ */
535
+ storage?: Pick<Storage, 'getItem' | 'setItem'> | null;
536
+ }
537
+
538
+ /**
539
+ * Keep selected store keys in sync with localStorage/sessionStorage:
540
+ * hydrates them on call, then saves on change.
541
+ *
542
+ * - Hydration assigns stored values through the proxy (subscribers fire).
543
+ * - Saves coalesce to one storage write per microtask and are skipped when
544
+ * the serialized snapshot is unchanged.
545
+ * - Storage failures (quota, corrupted JSON, unserializable values) warn
546
+ * on the console — never throw.
547
+ * - With no storage available (SSR), persistence is disabled with a warning.
548
+ *
549
+ * @param store - Reactive store created with state()
550
+ * @param storageKey - The storage entry name to read/write
551
+ * @param options - Persist options
552
+ * @returns Dispose function — stops watching and saving
553
+ * @throws {Error} If store is not reactive or storageKey is empty
554
+ *
555
+ * @example
556
+ * ```typescript
557
+ * import { state } from 'lume-js';
558
+ * import { persist } from 'lume-js/addons';
559
+ *
560
+ * const store = state({ todos: [], filter: 'all', draft: '' });
561
+ *
562
+ * // Hydrate + auto-save todos/filter; draft stays in-memory only
563
+ * const stop = persist(store, 'my-app', { keys: ['todos', 'filter'] });
564
+ * ```
565
+ */
566
+ export function persist(
567
+ store: ReactiveState<any>,
568
+ storageKey: string,
569
+ options?: PersistOptions
570
+ ): Unsubscribe;
571
+
@@ -1,3 +1,5 @@
1
+ import { REACTIVE_BRAND } from "../core/state.js";
2
+
1
3
  export { computed } from "./computed.js";
2
4
  export { watch } from "./watch.js";
3
5
  export { repeat, defaultFocusPreservation, defaultScrollPreservation } from "./repeat.js";
@@ -5,13 +7,22 @@ export { createDebugPlugin, debug } from "./debug.js";
5
7
  export { withPlugins } from "./withPlugins.js";
6
8
  export { createCleanupGroup } from "./cleanupGroup.js";
7
9
  export { hydrateState } from "./hydrateState.js";
10
+ export { persist } from "./persist.js";
8
11
 
9
12
  /**
10
13
  * Returns true if the value is a Lume reactive proxy created by state().
11
- * Uses duck-typing: checks for the presence of $subscribe.
14
+ *
15
+ * Checks the shared reactive brand first (a registry symbol stamped by
16
+ * state(), reliable across module copies), then falls back to duck-typing
17
+ * ($subscribe) for proxies from older lume-js versions whose brand was not
18
+ * shared. The brand check uses the `in` operator, which does not pass
19
+ * through the proxy `get` trap — calling isReactive inside an effect does
20
+ * not create a spurious dependency.
21
+ *
12
22
  * @param {any} obj
13
23
  * @returns {boolean}
14
24
  */
15
25
  export function isReactive(obj) {
16
- return !!(obj && typeof obj === 'object' && typeof obj.$subscribe === 'function');
26
+ return !!(obj && typeof obj === 'object' &&
27
+ (REACTIVE_BRAND in obj || typeof obj.$subscribe === 'function'));
17
28
  }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Lume-JS Persist Addon
3
+ *
4
+ * Keeps selected store keys in sync with localStorage/sessionStorage (or
5
+ * any Storage-like object): hydrates them on call, then saves on change.
6
+ *
7
+ * Usage:
8
+ * import { state } from "lume-js";
9
+ * import { persist } from "lume-js/addons";
10
+ *
11
+ * const store = state({ todos: [], filter: 'all', draft: '' });
12
+ *
13
+ * // Hydrate + auto-save todos/filter; draft stays in-memory only
14
+ * const stop = persist(store, 'my-app', { keys: ['todos', 'filter'] });
15
+ *
16
+ * Behavior:
17
+ * - Hydration assigns stored values through the proxy, so subscribers and
18
+ * bindings see them like any other write.
19
+ * - Saves are coalesced to one storage write per microtask, and skipped
20
+ * entirely when the serialized snapshot is unchanged.
21
+ * - Storage failures (quota, unavailable, corrupted JSON, unserializable
22
+ * values) are contained: a console warning, never a throw.
23
+ *
24
+ * @security Storage is same-origin but survives schema changes — hydration
25
+ * only assigns keys you watch (never unknown keys from storage), and the
26
+ * core set trap independently blocks prototype-polluting keys.
27
+ *
28
+ * @module addons/persist
29
+ */
30
+
31
+ import { logWarn } from '../utils/log.js';
32
+
33
+ /**
34
+ * Read and parse the stored JSON blob. Returns a plain object, or null
35
+ * when missing, corrupted, or not an object (warns on read errors).
36
+ */
37
+ function readStored(storage, storageKey) {
38
+ try {
39
+ const raw = storage.getItem(storageKey);
40
+ if (!raw) return null;
41
+ const data = JSON.parse(raw);
42
+ return data && typeof data === 'object' && !Array.isArray(data) ? data : null;
43
+ } catch {
44
+ logWarn(`[Lume.js] persist(): could not read "${storageKey}" — starting fresh`);
45
+ return null;
46
+ }
47
+ }
48
+
49
+ /** Serialize the watched subset of the store. May throw (circular refs). */
50
+ function serializeKeys(store, watched) {
51
+ const out = {};
52
+ for (const k of watched) out[k] = store[k];
53
+ return JSON.stringify(out);
54
+ }
55
+
56
+ /**
57
+ * Sync store keys with a Storage object.
58
+ *
59
+ * @param {object} store - Reactive store created with state()
60
+ * @param {string} storageKey - The storage entry name to read/write
61
+ * @param {object} [options]
62
+ * @param {string[]} [options.keys] - Keys to persist. Default: all own
63
+ * non-$ keys of the store at call time.
64
+ * @param {Storage} [options.storage] - Storage object. Default: localStorage.
65
+ * Pass sessionStorage for per-tab persistence.
66
+ * @returns {function} Dispose function — stops watching and saving.
67
+ */
68
+ export function persist(store, storageKey, options = {}) {
69
+ if (!store || typeof store.$subscribe !== 'function') {
70
+ throw new Error('[Lume.js] persist() requires a reactive store from state()');
71
+ }
72
+ if (typeof storageKey !== 'string' || storageKey.length === 0) {
73
+ throw new Error('[Lume.js] persist() requires a non-empty storage key');
74
+ }
75
+
76
+ const storage = options.storage !== undefined
77
+ ? options.storage
78
+ : globalThis.localStorage;
79
+
80
+ if (!storage || typeof storage.getItem !== 'function') {
81
+ logWarn('[Lume.js] persist(): no storage available — persistence disabled');
82
+ return () => {};
83
+ }
84
+
85
+ const watched = Array.isArray(options.keys) && options.keys.length > 0
86
+ ? options.keys.slice()
87
+ : Object.keys(store).filter(k => !k.startsWith('$'));
88
+
89
+ // ── Hydrate ────────────────────────────────────────────────────────────
90
+ // Only watched keys are assigned — stale storage can't inject others.
91
+ const stored = readStored(storage, storageKey);
92
+ if (stored) {
93
+ for (const k of watched) {
94
+ if (Object.prototype.hasOwnProperty.call(stored, k)) {
95
+ store[k] = stored[k];
96
+ }
97
+ }
98
+ }
99
+
100
+ // ── Save on change ─────────────────────────────────────────────────────
101
+ // Remember what storage holds (post-hydration) so unchanged flushes —
102
+ // including the hydration echo itself — skip the write.
103
+ let lastWritten = null;
104
+ try {
105
+ lastWritten = serializeKeys(store, watched);
106
+ } catch {
107
+ // Unserializable initial state: first save attempt will warn.
108
+ }
109
+
110
+ let scheduled = false;
111
+ let disposed = false;
112
+
113
+ const flushSave = () => {
114
+ scheduled = false;
115
+ if (disposed) return;
116
+
117
+ let json;
118
+ try {
119
+ json = serializeKeys(store, watched);
120
+ } catch (err) {
121
+ logWarn('[Lume.js] persist(): state not serializable — skipping save', err);
122
+ return;
123
+ }
124
+ if (json === lastWritten) return;
125
+
126
+ try {
127
+ storage.setItem(storageKey, json);
128
+ lastWritten = json;
129
+ } catch (err) {
130
+ logWarn('[Lume.js] persist(): could not write — storage full or unavailable?', err);
131
+ }
132
+ };
133
+
134
+ const save = () => {
135
+ if (scheduled) return;
136
+ scheduled = true;
137
+ queueMicrotask(flushSave);
138
+ };
139
+
140
+ const unsubs = watched.map(k => {
141
+ let first = true;
142
+ return store.$subscribe(k, () => {
143
+ if (first) { first = false; return; } // skip $subscribe's immediate call
144
+ save();
145
+ });
146
+ });
147
+
148
+ return () => {
149
+ disposed = true;
150
+ while (unsubs.length) unsubs.pop()();
151
+ };
152
+ }
@@ -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,64 @@
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 its single root element.
118
+ * Accepts true (first <template> inside the container), a CSS selector,
119
+ * or an HTMLTemplateElement directly.
120
+ */
121
+ function resolveTemplateRoot(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.content.firstElementChild;
135
+ }
136
+
137
+ /**
138
+ * Collect [data-bind] nodes of a cloned item element into a compiled
139
+ * binding list. Paths are resolved against the ITEM (not the store):
140
+ * data-bind="name" → item.name
141
+ * data-bind="user.city" → item.user.city
142
+ * data-bind="$item" → the item itself (for primitive arrays)
143
+ * data-bind="$index" → the item's current index
144
+ */
145
+ function collectItemBindings(el) {
146
+ const bindings = [];
147
+ const add = (node) => {
148
+ const path = node.getAttribute('data-bind');
149
+ bindings.push({ node, path, keys: path === '$item' || path === '$index' ? null : path.split('.') });
150
+ };
151
+ if (el.hasAttribute('data-bind')) add(el);
152
+ for (const node of el.querySelectorAll('[data-bind]')) add(node);
153
+ return bindings;
154
+ }
155
+
156
+ function applyItemBindings(bindings, item, index) {
157
+ for (const b of bindings) {
158
+ let val;
159
+ if (b.path === '$index') {
160
+ val = index;
161
+ } else if (b.path === '$item') {
162
+ val = item;
163
+ } else {
164
+ val = item;
165
+ for (let i = 0; i < b.keys.length && val != null; i++) {
166
+ val = val[b.keys[i]];
167
+ }
168
+ }
169
+ applyBindValue(b.node, val);
170
+ }
171
+ }
82
172
 
83
173
  /**
84
174
  * Default focus preservation strategy
@@ -173,7 +263,8 @@ export function defaultScrollPreservation(container, context = {}) {
173
263
  * @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
264
  * @param {Function} [options.update] - Function for data binding: (item, element, index, { isFirstRender }) => void. Skipped if same item reference AND same index.
175
265
  * @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
266
+ * @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.
267
+ * @param {string|Function} [options.element='div'] - Element tag name or factory function (ignored when options.template is set)
177
268
  * @param {Function|null} [options.preserveFocus=defaultFocusPreservation] - Focus preservation strategy (null to disable)
178
269
  * @param {Function|null} [options.preserveScroll=defaultScrollPreservation] - Scroll preservation strategy (null to disable)
179
270
  * @returns {Function} Cleanup function
@@ -186,6 +277,7 @@ export function repeat(container, store, arrayKey, options) {
186
277
  create,
187
278
  update,
188
279
  remove,
280
+ template = null,
189
281
  element = 'div',
190
282
  preserveFocus = defaultFocusPreservation,
191
283
  preserveScroll = defaultScrollPreservation
@@ -206,7 +298,15 @@ export function repeat(container, store, arrayKey, options) {
206
298
  throw new Error('[Lume.js] repeat(): options.key must be a function');
207
299
  }
208
300
 
209
- if (typeof render !== 'function' && typeof create !== 'function') {
301
+ // Template mode: structure and data binding come from the <template>;
302
+ // render/create/update are all optional on top of it.
303
+ const templateRoot = template ? resolveTemplateRoot(template, containerEl) : null;
304
+
305
+ if (templateRoot && typeof render === 'function') {
306
+ logWarn('[Lume.js] repeat(): options.render is ignored when options.template is set — use create/update instead');
307
+ }
308
+
309
+ if (!templateRoot && typeof render !== 'function' && typeof create !== 'function') {
210
310
  throw new Error('[Lume.js] repeat(): options.render or options.create must be a function');
211
311
  }
212
312
 
@@ -218,9 +318,12 @@ export function repeat(container, store, arrayKey, options) {
218
318
  const prevIndexByKey = new Map();
219
319
  // key -> cleanup function returned by create()
220
320
  const cleanupByKey = new Map();
321
+ // key -> compiled [data-bind] nodes of the clone (template mode only)
322
+ const bindingsByKey = new Map();
221
323
  const seenKeys = new Set();
222
324
 
223
325
  function createElement() {
326
+ if (templateRoot) return templateRoot.cloneNode(true);
224
327
  return typeof element === 'function'
225
328
  ? element()
226
329
  : document.createElement(element);
@@ -298,10 +401,13 @@ export function repeat(container, store, arrayKey, options) {
298
401
  if (isFirstRender) {
299
402
  el = createElement();
300
403
  elementsByKey.set(k, el);
404
+ if (templateRoot) {
405
+ bindingsByKey.set(k, collectItemBindings(el));
406
+ }
301
407
  }
302
408
 
303
409
  try {
304
- // Call create for new elements (DOM structure)
410
+ // Call create for new elements (DOM structure / event listeners)
305
411
  if (isFirstRender && create) {
306
412
  const cleanup = create(item, el, i);
307
413
  if (typeof cleanup === 'function') {
@@ -309,11 +415,17 @@ export function repeat(container, store, arrayKey, options) {
309
415
  }
310
416
  }
311
417
 
312
- // Call update for data binding (new and existing elements)
418
+ // Data binding (new and existing elements)
313
419
  // Skip if same item reference AND same index (optimization)
314
420
  const prevItem = prevItemsByKey.get(k);
315
421
  const prevIndex = prevIndexByKey.get(k);
316
- if (update) {
422
+ if (templateRoot) {
423
+ if (prevItem !== item || prevIndex !== i) {
424
+ applyItemBindings(bindingsByKey.get(k), item, i);
425
+ // update is optional extra binding on top of the template
426
+ if (update) update(item, el, i, { isFirstRender });
427
+ }
428
+ } else if (update) {
317
429
  if (prevItem !== item || prevIndex !== i) {
318
430
  update(item, el, i, { isFirstRender });
319
431
  }
@@ -359,6 +471,7 @@ export function repeat(container, store, arrayKey, options) {
359
471
  prevItemsByKey.delete(k);
360
472
  prevIndexByKey.delete(k);
361
473
  cleanupByKey.delete(k);
474
+ bindingsByKey.delete(k);
362
475
  }
363
476
  }
364
477
  }
@@ -402,6 +515,7 @@ export function repeat(container, store, arrayKey, options) {
402
515
  prevItemsByKey.clear();
403
516
  prevIndexByKey.clear();
404
517
  cleanupByKey.clear();
518
+ bindingsByKey.clear();
405
519
  seenKeys.clear();
406
520
  };
407
521
  }
@@ -431,6 +545,7 @@ export function repeat(container, store, arrayKey, options) {
431
545
  prevItemsByKey.clear();
432
546
  prevIndexByKey.clear();
433
547
  cleanupByKey.clear();
548
+ bindingsByKey.clear();
434
549
  seenKeys.clear();
435
550
  };
436
551
  }
@@ -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);