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.
@@ -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);
package/src/core/state.js CHANGED
@@ -12,6 +12,7 @@
12
12
  * - $subscribe for listening to key changes
13
13
  * - Cleanup with unsubscribe
14
14
  * - Per-state microtask batching for writes
15
+ * - batch() for grouping writes across states with cross-store effect dedupe
15
16
  * - Scope-based read tracking via withReadObserver (multi-observer safe)
16
17
  *
17
18
  * Usage:
@@ -23,6 +24,7 @@
23
24
  */
24
25
 
25
26
  import { logError, logWarn } from '../utils/log.js';
27
+ import { enqueueIfBatching, MAX_FLUSH_ITERATIONS } from './batch.js';
26
28
 
27
29
  // Per-state batching – each state object maintains its own microtask flush.
28
30
  // This keeps effects simple and aligned with Lume's minimal philosophy.
@@ -48,6 +50,22 @@ import { logError, logWarn } from '../utils/log.js';
48
50
  // own independent Set via ES module / CommonJS isolation.
49
51
  const readers = new Set();
50
52
 
53
+ /**
54
+ * Brand symbol stamped on every object passed to state().
55
+ *
56
+ * Uses the global symbol registry (Symbol.for) so independent copies of
57
+ * lume-js on the same page (e.g. a CDN build next to a bundled chunk)
58
+ * agree on the same brand. This is a type tag for reliable detection
59
+ * (see isReactive in addons), not a security boundary — any code can
60
+ * stamp it.
61
+ *
62
+ * Internal API — exported for addons; not re-exported from the package root.
63
+ */
64
+ export const REACTIVE_BRAND = Symbol.for('lume.reactive');
65
+
66
+ // batch() lives in ./batch.js (which never imports this module — no cycle).
67
+ // state.js participates through enqueueIfBatching in scheduleFlush below.
68
+
51
69
  /**
52
70
  * Run a function with a read observer active.
53
71
  * The observer receives (proxy, key, registerEffect) for every property read.
@@ -89,6 +107,49 @@ export function state(obj) {
89
107
  const beforeFlushHooks = [];
90
108
  let flushScheduled = false;
91
109
 
110
+ // ── Flush steps ──────────────────────────────────────────────────────
111
+ // Named pieces shared by the per-state microtask flush and batch().
112
+
113
+ function runBeforeFlushHooks() {
114
+ for (let i = 0; i < beforeFlushHooks.length; i++) {
115
+ try {
116
+ beforeFlushHooks[i]();
117
+ } catch (err) {
118
+ logError('[Lume.js state] Error in beforeFlush hook:', err);
119
+ }
120
+ }
121
+ }
122
+
123
+ function notifySubscribers() {
124
+ for (const [key, value] of pendingNotifications) {
125
+ if (listeners[key]) {
126
+ const subs = listeners[key];
127
+ let i = 0;
128
+ while (i < subs.length) {
129
+ const fn = subs[i];
130
+ try {
131
+ fn(value);
132
+ } catch (err) {
133
+ logError(`[Lume.js state] Error notifying subscriber for key "${String(key)}":`, err);
134
+ }
135
+ // Only advance if fn wasn't removed (something shifted into its place)
136
+ if (subs[i] === fn) i++;
137
+ }
138
+ }
139
+ }
140
+ pendingNotifications.clear();
141
+ }
142
+
143
+ /** Drain queued effects (Set deduplicates) into an array. */
144
+ function takeEffects() {
145
+ const effects = Array.from(pendingEffects);
146
+ pendingEffects.clear();
147
+ return effects;
148
+ }
149
+
150
+ // Handle this state gives batch() — flush steps only, no live queues.
151
+ const batchHandle = { runBeforeFlushHooks, notifySubscribers, takeEffects };
152
+
92
153
  /**
93
154
  * Schedule a single microtask flush for this state object.
94
155
  *
@@ -100,57 +161,28 @@ export function state(obj) {
100
161
  *
101
162
  * Notes:
102
163
  * - Batching is per state; effects that depend on multiple states
103
- * may run once per state that changed (by design).
164
+ * may run once per state that changed (by design). Use batch() to
165
+ * group writes across states and run such effects once.
166
+ * - Inside batch(), the microtask is skipped: the state enqueues
167
+ * itself for the synchronous flush at the end of the batch.
104
168
  */
105
169
  function scheduleFlush() {
170
+ // Inside batch(): the batch captures this state's flush handle and
171
+ // flushes synchronously at the end — skip the microtask.
172
+ if (enqueueIfBatching(batchHandle)) return;
173
+
106
174
  if (flushScheduled) return;
107
175
 
108
176
  flushScheduled = true;
109
- // eslint-disable-next-line sonarjs/cognitive-complexity -- single-pass flush loop: hooks → subscribers → effects → cycle detection; must stay atomic
110
177
  queueMicrotask(() => {
111
178
  let iterations = 0;
112
- const MAX_ITERATIONS = 100;
113
179
 
114
180
  try {
115
- while ((pendingNotifications.size > 0 || pendingEffects.size > 0) && iterations < MAX_ITERATIONS) {
181
+ while ((pendingNotifications.size > 0 || pendingEffects.size > 0) && iterations < MAX_FLUSH_ITERATIONS) {
116
182
  iterations++;
117
-
118
- // Run registered before-flush hooks (e.g. plugin onNotify)
119
- for (let i = 0; i < beforeFlushHooks.length; i++) {
120
- try {
121
- beforeFlushHooks[i]();
122
- } catch (err) {
123
- logError('[Lume.js state] Error in beforeFlush hook:', err);
124
- }
125
- }
126
-
127
- // Notify all subscribers of changed keys
128
- for (const [key, value] of pendingNotifications) {
129
- if (listeners[key]) {
130
- const subs = listeners[key];
131
- let i = 0;
132
- while (i < subs.length) {
133
- const fn = subs[i];
134
- try {
135
- fn(value);
136
- } catch (err) {
137
- logError(`[Lume.js state] Error notifying subscriber for key "${String(key)}":`, err);
138
- }
139
- // Only advance if fn wasn't removed (something shifted into its place)
140
- if (subs[i] === fn) i++;
141
- }
142
- }
143
- }
144
-
145
- pendingNotifications.clear();
146
-
147
- // Run each effect exactly once (Set deduplicates)
148
- const effects = new Array(pendingEffects.size);
149
- let idx = 0;
150
- for (const effect of pendingEffects) {
151
- effects[idx++] = effect;
152
- }
153
- pendingEffects.clear();
183
+ runBeforeFlushHooks();
184
+ notifySubscribers();
185
+ const effects = takeEffects();
154
186
  for (let i = 0; i < effects.length; i++) {
155
187
  try {
156
188
  effects[i]();
@@ -163,7 +195,7 @@ export function state(obj) {
163
195
  flushScheduled = false;
164
196
  }
165
197
 
166
- if (iterations >= MAX_ITERATIONS) {
198
+ if (iterations >= MAX_FLUSH_ITERATIONS) {
167
199
  logError(
168
200
  '[Lume.js state] Maximum flush iterations reached (100). ' +
169
201
  'This usually indicates an infinite loop caused by an effect or computed mutating state it depends on.'
@@ -172,29 +204,47 @@ export function state(obj) {
172
204
  });
173
205
  }
174
206
 
175
- // Brand symbol for type-level reactive identification
176
- const REACTIVE_BRAND = Symbol('lume.reactive');
177
- obj[REACTIVE_BRAND] = true;
207
+ // Stamp the shared brand (non-enumerable: spreads/Object.assign copies
208
+ // of a store do not inherit the brand and won't masquerade as reactive).
209
+ Object.defineProperty(obj, REACTIVE_BRAND, { value: true });
178
210
 
179
- // Defined once per state instance — not per property read — to avoid per-read closure allocation.
180
- const registerEffect = (key, executeFn) => {
181
- if (!listeners[key]) listeners[key] = [];
182
-
183
- const callback = () => {
184
- pendingEffects.add(executeFn);
185
- };
186
-
187
- listeners[key].push(callback);
211
+ const MAX_SUBSCRIBERS = 1000;
212
+ const noopUnsubscribe = () => {};
188
213
 
214
+ /**
215
+ * Shared listener registration with a per-key cap (subscriber DoS
216
+ * protection). Applied identically to $subscribe callbacks and effect
217
+ * subscriptions so both paths degrade the same way: a loud console
218
+ * error and a no-op unsubscribe.
219
+ */
220
+ function addListener(key, fn, kind) {
221
+ if (!listeners[key]) listeners[key] = [];
222
+ if (listeners[key].length >= MAX_SUBSCRIBERS) {
223
+ logError(
224
+ `[Lume.js state] Subscriber limit (${MAX_SUBSCRIBERS}) reached for key "${String(key)}". ` +
225
+ `${kind} ignored — it will NOT receive updates. ` +
226
+ 'This usually means subscriptions are created in a loop without cleanup.'
227
+ );
228
+ return noopUnsubscribe;
229
+ }
230
+ listeners[key].push(fn);
189
231
  return () => {
190
232
  if (listeners[key]) {
191
- const idx = listeners[key].indexOf(callback);
233
+ const idx = listeners[key].indexOf(fn);
192
234
  if (idx !== -1) {
193
235
  listeners[key].splice(idx, 1);
194
236
  if (listeners[key].length === 0) delete listeners[key];
195
237
  }
196
238
  }
197
239
  };
240
+ }
241
+
242
+ // Defined once per state instance — not per property read — to avoid per-read closure allocation.
243
+ const registerEffect = (key, executeFn) => {
244
+ const callback = () => {
245
+ pendingEffects.add(executeFn);
246
+ };
247
+ return addListener(key, callback, 'Effect subscription');
198
248
  };
199
249
 
200
250
  const BLOCKED_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
@@ -269,33 +319,20 @@ export function state(obj) {
269
319
  };
270
320
  };
271
321
 
272
- const MAX_SUBSCRIBERS = 1000;
273
-
274
322
  obj.$subscribe = (key, fn) => {
275
323
  if (typeof fn !== 'function') {
276
324
  throw new Error('Subscriber must be a function');
277
325
  }
278
326
 
279
- if (!listeners[key]) listeners[key] = [];
280
- if (listeners[key].length >= MAX_SUBSCRIBERS) {
281
- logWarn(`[Lume.js state] Subscriber limit (${MAX_SUBSCRIBERS}) reached for key "${key}". New subscriber ignored.`);
282
- return () => {};
283
- }
284
- listeners[key].push(fn);
327
+ const unsubscribe = addListener(key, fn, 'New subscriber');
328
+
329
+ // Over the cap: listener was not added, skip the immediate call too
330
+ if (unsubscribe === noopUnsubscribe) return unsubscribe;
285
331
 
286
332
  // Call immediately with current value (NOT batched)
287
333
  fn(proxy[key]);
288
334
 
289
- // Return unsubscribe function
290
- return () => {
291
- if (listeners[key]) {
292
- const idx = listeners[key].indexOf(fn);
293
- if (idx !== -1) {
294
- listeners[key].splice(idx, 1);
295
- if (listeners[key].length === 0) delete listeners[key];
296
- }
297
- }
298
- };
335
+ return unsubscribe;
299
336
  };
300
337
 
301
338
  return proxy;
@@ -85,6 +85,30 @@ export function classToggle(...names: string[]): Handler[];
85
85
  */
86
86
  export function stringAttr(name: string): Handler;
87
87
 
88
+ /**
89
+ * Create handlers for declarative event wiring.
90
+ * Each type creates a handler: data-on{type}="key" wires the function held
91
+ * at that store key as a DOM event listener.
92
+ *
93
+ * Reactive like any binding: assigning a new function to the key re-wires
94
+ * the listener; assigning null/undefined detaches it. Non-function truthy
95
+ * values detach with a console warning.
96
+ *
97
+ * Returns an array — pass directly to handlers (auto-flattened by bindDom).
98
+ *
99
+ * @param types - DOM event types ('click', 'input', 'submit', ...)
100
+ *
101
+ * @example
102
+ * ```typescript
103
+ * const store = state({
104
+ * addTodo: (event: Event) => { ... }
105
+ * });
106
+ * bindDom(root, store, { handlers: [on('click')] });
107
+ * // <button data-onclick="addTodo">Add</button>
108
+ * ```
109
+ */
110
+ export function on(...types: string[]): Handler[];
111
+
88
112
  /** Form-related handlers preset (readonly) */
89
113
  export const formHandlers: Handler[];
90
114
 
@@ -25,5 +25,6 @@ export { boolAttr } from './boolAttr.js';
25
25
  export { ariaAttr } from './ariaAttr.js';
26
26
  export { classToggle } from './classToggle.js';
27
27
  export { stringAttr } from './stringAttr.js';
28
+ export { on } from './on.js';
28
29
  export { htmlAttrs } from './htmlAttrs.js';
29
30
  export { formHandlers, a11yHandlers } from './presets.js';
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Create handlers for declarative event wiring.
3
+ * Each type creates a handler: data-on{type}="key" wires the function held
4
+ * at that store key as a DOM event listener.
5
+ *
6
+ * <button data-onclick="addTodo">Add</button>
7
+ *
8
+ * const store = state({
9
+ * addTodo: (event) => { ... } // a plain function in state
10
+ * });
11
+ * bindDom(root, store, { handlers: [on('click')] });
12
+ *
13
+ * Reactive like any binding: assigning a new function to the key re-wires
14
+ * the listener; assigning null/undefined detaches it.
15
+ *
16
+ * Returns an array — pass directly to handlers (auto-flattened by bindDom).
17
+ *
18
+ * @security Same trust model as data-bind: an injected data-on* attribute
19
+ * can only reference functions that already exist in reachable state — no
20
+ * expressions, no eval. Ensure your HTML is trusted or sanitized.
21
+ *
22
+ * Note: bindDom's cleanup stops future re-wiring but does not detach
23
+ * listeners already attached to elements that remain in the DOM. Discard
24
+ * the bound subtree (the normal SPA teardown) to drop them.
25
+ *
26
+ * @param {...string} types - DOM event types ('click', 'input', 'submit', ...)
27
+ * @returns {Array<{ attr: string, apply: function }>}
28
+ */
29
+
30
+ import { logWarn } from '../utils/log.js';
31
+
32
+ // element → Map<eventType, listener> — tracks what we attached so a
33
+ // re-assigned store key swaps the listener instead of stacking a second one.
34
+ const attached = new WeakMap();
35
+
36
+ export function on(...types) {
37
+ return types.map(type => ({
38
+ attr: `data-on${type}`,
39
+ apply(el, val) {
40
+ let byType = attached.get(el);
41
+ const prev = byType ? byType.get(type) : undefined;
42
+
43
+ if (prev) {
44
+ el.removeEventListener(type, prev);
45
+ byType.delete(type);
46
+ }
47
+
48
+ if (typeof val === 'function') {
49
+ el.addEventListener(type, val);
50
+ if (!byType) {
51
+ byType = new Map();
52
+ attached.set(el, byType);
53
+ }
54
+ byType.set(type, val);
55
+ } else if (val != null) {
56
+ logWarn(`[Lume.js] on('${type}'): bound value is not a function — listener detached`);
57
+ }
58
+ }
59
+ }));
60
+ }
package/src/index.d.ts CHANGED
@@ -353,6 +353,46 @@ export function effect(fn: () => void): Unsubscribe;
353
353
  */
354
354
  export function effect(fn: () => void, deps: EffectDependency[]): Unsubscribe;
355
355
 
356
+ /**
357
+ * Group multiple state writes and flush them together, synchronously,
358
+ * when the outermost batch() returns.
359
+ *
360
+ * Guarantees:
361
+ * - Subscribers see only the final value of each key written in the batch.
362
+ * - An effect depending on several stores mutated in the batch runs exactly
363
+ * ONCE (normal microtask batching is per-state: such effects run once per
364
+ * mutated store).
365
+ * - Nested batch() calls are absorbed into the outermost batch.
366
+ * - If fn throws, writes made before the throw still flush, then the error
367
+ * propagates.
368
+ *
369
+ * fn must be synchronous. Writes after an `await` fall back to normal
370
+ * per-state microtask flushing (a console warning is logged if fn returns
371
+ * a Promise).
372
+ *
373
+ * @param fn - Function performing state writes
374
+ * @returns The return value of fn
375
+ * @throws {Error} If fn is not a function
376
+ *
377
+ * @example
378
+ * ```typescript
379
+ * import { state, effect, batch } from 'lume-js';
380
+ *
381
+ * const a = state({ value: 1 });
382
+ * const b = state({ value: 2 });
383
+ *
384
+ * effect(() => {
385
+ * render(a.value + b.value);
386
+ * });
387
+ *
388
+ * batch(() => {
389
+ * a.value = 10;
390
+ * b.value = 20;
391
+ * }); // render() ran exactly once, seeing 30
392
+ * ```
393
+ */
394
+ export function batch<T>(fn: () => T): T;
395
+
356
396
  /**
357
397
  * Run a function with a read observer active.
358
398
  *
package/src/index.js CHANGED
@@ -5,12 +5,14 @@
5
5
  * - state(): create reactive state
6
6
  * - bindDom(): zero-runtime DOM binding
7
7
  * - effect(): reactive effect with automatic dependency tracking
8
+ * - batch(): group writes across states, flush once synchronously
8
9
  * - withReadObserver(): advanced API for custom reactive primitives
9
10
  *
10
11
  * Usage:
11
- * import { state, bindDom, effect } from "lume-js";
12
+ * import { state, bindDom, effect, batch } from "lume-js";
12
13
  */
13
14
 
14
15
  export { state, withReadObserver } from "./core/state.js";
16
+ export { batch } from "./core/batch.js";
15
17
  export { bindDom } from "./core/bindDom.js";
16
18
  export { effect } from "./core/effect.js";
package/src/state.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Lume.js Universal State Entry — TypeScript Definitions
3
+ *
4
+ * The DOM-free kernel: state() + batch() + withReadObserver().
5
+ * Import from "lume-js/state" in Node, Deno, workers, and CLI tools.
6
+ * Types are shared with the full core entry.
7
+ */
8
+
9
+ export type {
10
+ Unsubscribe,
11
+ Subscriber,
12
+ Plugin,
13
+ TypedPlugin,
14
+ ReactiveState,
15
+ } from './index.js';
16
+
17
+ export { state, batch, withReadObserver } from './index.js';
package/src/state.js ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Lume-JS Universal State Entry
3
+ *
4
+ * The DOM-free kernel: reactive state + cross-store batching. Runs anywhere
5
+ * JavaScript runs — Node, Deno, Bun, workers, CLI tools, browsers.
6
+ *
7
+ * Use this entry when you don't need DOM binding:
8
+ *
9
+ * import { state, batch } from "lume-js/state";
10
+ *
11
+ * const store = state({ count: 0 });
12
+ * store.$subscribe("count", v => console.log("count:", v));
13
+ *
14
+ * batch(() => {
15
+ * store.count = 1;
16
+ * store.count = 2;
17
+ * }); // subscribers see only 2, synchronously
18
+ *
19
+ * For DOM binding and effects, use the full core instead:
20
+ *
21
+ * import { state, bindDom, effect, batch } from "lume-js";
22
+ */
23
+
24
+ export { state, withReadObserver } from "./core/state.js";
25
+ export { batch } from "./core/batch.js";