lume-js 2.2.0 → 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.
Files changed (43) hide show
  1. package/README.md +90 -19
  2. package/dist/addons.min.mjs +1 -1
  3. package/dist/addons.mjs +160 -6
  4. package/dist/addons.mjs.map +1 -1
  5. package/dist/handlers.min.mjs +1 -1
  6. package/dist/handlers.mjs +38 -2
  7. package/dist/handlers.mjs.map +1 -1
  8. package/dist/index.min.mjs +1 -1
  9. package/dist/index.mjs +4 -141
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/lume.global.js +1 -1
  12. package/dist/lume.global.js.map +1 -1
  13. package/dist/shared-Bk_gndPJ.mjs +232 -0
  14. package/dist/shared-Bk_gndPJ.mjs.map +1 -0
  15. package/dist/shared-DNe4ez8V.mjs +249 -0
  16. package/dist/shared-DNe4ez8V.mjs.map +1 -0
  17. package/dist/shared-DmpHYKx7.mjs +15 -0
  18. package/dist/shared-DmpHYKx7.mjs.map +1 -0
  19. package/dist/state.min.mjs +1 -0
  20. package/dist/state.mjs +7 -0
  21. package/dist/state.mjs.map +1 -0
  22. package/package.json +5 -1
  23. package/src/addons/computed.js +5 -0
  24. package/src/addons/hydrateState.js +32 -4
  25. package/src/addons/index.d.ts +70 -2
  26. package/src/addons/index.js +13 -2
  27. package/src/addons/persist.js +152 -0
  28. package/src/addons/repeat.js +120 -5
  29. package/src/addons/withPlugins.js +7 -1
  30. package/src/core/batch.js +139 -0
  31. package/src/core/bindDom.js +12 -3
  32. package/src/core/effect.js +34 -5
  33. package/src/core/state.js +124 -68
  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 +14 -2
  38. package/src/index.d.ts +40 -0
  39. package/src/index.js +3 -1
  40. package/src/state.d.ts +17 -0
  41. package/src/state.js +25 -0
  42. package/dist/shared-Dcokqj5a.mjs +0 -249
  43. package/dist/shared-Dcokqj5a.mjs.map +0 -1
@@ -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
+ }
@@ -24,6 +24,11 @@
24
24
  * Usage:
25
25
  * import { bindDom } from "lume-js";
26
26
  * const cleanup = bindDom(document.body, store);
27
+ *
28
+ * @security `data-bind` attribute values are resolved once at bind time and
29
+ * trusted as state path expressions. If an attacker can inject `data-bind`
30
+ * attributes into the DOM, they can subscribe to any reachable reactive state.
31
+ * Ensure your HTML is trusted or sanitize it before calling bindDom().
27
32
  */
28
33
 
29
34
  import { logWarn } from '../utils/log.js';
@@ -141,7 +146,7 @@ function handleDataBind(el, store, path, bindingMap) {
141
146
  if (!result) return null;
142
147
 
143
148
  const { target, key } = result;
144
- const unsub = target.$subscribe(key, val => updateElement(el, val));
149
+ const unsub = target.$subscribe(key, val => applyBindValue(el, val));
145
150
 
146
151
  if (isFormInput(el)) {
147
152
  bindingMap.set(el, { target, key });
@@ -200,9 +205,13 @@ function resolveProp(store, path) {
200
205
  }
201
206
 
202
207
  /**
203
- * 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.
204
213
  */
205
- function updateElement(el, val) {
214
+ export function applyBindValue(el, val) {
206
215
  if (el.tagName === "INPUT") {
207
216
  if (el.type === "checkbox") el.checked = Boolean(val);
208
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);
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:
@@ -22,7 +23,8 @@
22
23
  * unsub(); // cleanup
23
24
  */
24
25
 
25
- import { logError } from '../utils/log.js';
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 } 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.
@@ -55,6 +73,12 @@ const readers = new Set();
55
73
  *
56
74
  * Internal API — used by effect.js for auto-tracking. May be stabilized
57
75
  * for third-party addons in a future release.
76
+ *
77
+ * @security The observer sees reads from ALL state instances within the same
78
+ * module instance, including nested scopes. Only pass trusted observer functions.
79
+ * A future scoped variant (e.g., scopedReadObserver(store, fn)) may limit
80
+ * observation to a single state instance.
81
+ *
58
82
  * @param {function} onRead - Called on each property access inside fn
59
83
  * @param {function} fn - The function to run under observation
60
84
  */
@@ -83,6 +107,49 @@ export function state(obj) {
83
107
  const beforeFlushHooks = [];
84
108
  let flushScheduled = false;
85
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
+
86
153
  /**
87
154
  * Schedule a single microtask flush for this state object.
88
155
  *
@@ -94,57 +161,28 @@ export function state(obj) {
94
161
  *
95
162
  * Notes:
96
163
  * - Batching is per state; effects that depend on multiple states
97
- * 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.
98
168
  */
99
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
+
100
174
  if (flushScheduled) return;
101
175
 
102
176
  flushScheduled = true;
103
- // eslint-disable-next-line sonarjs/cognitive-complexity -- single-pass flush loop: hooks → subscribers → effects → cycle detection; must stay atomic
104
177
  queueMicrotask(() => {
105
178
  let iterations = 0;
106
- const MAX_ITERATIONS = 100;
107
179
 
108
180
  try {
109
- while ((pendingNotifications.size > 0 || pendingEffects.size > 0) && iterations < MAX_ITERATIONS) {
181
+ while ((pendingNotifications.size > 0 || pendingEffects.size > 0) && iterations < MAX_FLUSH_ITERATIONS) {
110
182
  iterations++;
111
-
112
- // Run registered before-flush hooks (e.g. plugin onNotify)
113
- for (let i = 0; i < beforeFlushHooks.length; i++) {
114
- try {
115
- beforeFlushHooks[i]();
116
- } catch (err) {
117
- logError('[Lume.js state] Error in beforeFlush hook:', err);
118
- }
119
- }
120
-
121
- // Notify all subscribers of changed keys
122
- for (const [key, value] of pendingNotifications) {
123
- if (listeners[key]) {
124
- const subs = listeners[key];
125
- let i = 0;
126
- while (i < subs.length) {
127
- const fn = subs[i];
128
- try {
129
- fn(value);
130
- } catch (err) {
131
- logError(`[Lume.js state] Error notifying subscriber for key "${String(key)}":`, err);
132
- }
133
- // Only advance if fn wasn't removed (something shifted into its place)
134
- if (subs[i] === fn) i++;
135
- }
136
- }
137
- }
138
-
139
- pendingNotifications.clear();
140
-
141
- // Run each effect exactly once (Set deduplicates)
142
- const effects = new Array(pendingEffects.size);
143
- let idx = 0;
144
- for (const effect of pendingEffects) {
145
- effects[idx++] = effect;
146
- }
147
- pendingEffects.clear();
183
+ runBeforeFlushHooks();
184
+ notifySubscribers();
185
+ const effects = takeEffects();
148
186
  for (let i = 0; i < effects.length; i++) {
149
187
  try {
150
188
  effects[i]();
@@ -157,7 +195,7 @@ export function state(obj) {
157
195
  flushScheduled = false;
158
196
  }
159
197
 
160
- if (iterations >= MAX_ITERATIONS) {
198
+ if (iterations >= MAX_FLUSH_ITERATIONS) {
161
199
  logError(
162
200
  '[Lume.js state] Maximum flush iterations reached (100). ' +
163
201
  'This usually indicates an infinite loop caused by an effect or computed mutating state it depends on.'
@@ -166,31 +204,51 @@ export function state(obj) {
166
204
  });
167
205
  }
168
206
 
169
- // Brand symbol for type-level reactive identification
170
- const REACTIVE_BRAND = Symbol('lume.reactive');
171
- 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 });
172
210
 
173
- // Defined once per state instance — not per property read — to avoid per-read closure allocation.
174
- const registerEffect = (key, executeFn) => {
175
- if (!listeners[key]) listeners[key] = [];
176
-
177
- const callback = () => {
178
- pendingEffects.add(executeFn);
179
- };
180
-
181
- listeners[key].push(callback);
211
+ const MAX_SUBSCRIBERS = 1000;
212
+ const noopUnsubscribe = () => {};
182
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);
183
231
  return () => {
184
232
  if (listeners[key]) {
185
- const idx = listeners[key].indexOf(callback);
233
+ const idx = listeners[key].indexOf(fn);
186
234
  if (idx !== -1) {
187
235
  listeners[key].splice(idx, 1);
188
236
  if (listeners[key].length === 0) delete listeners[key];
189
237
  }
190
238
  }
191
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');
192
248
  };
193
249
 
250
+ const BLOCKED_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
251
+
194
252
  const proxy = new Proxy(obj, {
195
253
  get(target, key) {
196
254
  // Skip effect tracking for internal meta methods (e.g. $subscribe)
@@ -211,6 +269,11 @@ export function state(obj) {
211
269
  },
212
270
 
213
271
  set(target, key, value) {
272
+ if (typeof key === 'string' && BLOCKED_KEYS.has(key)) {
273
+ logWarn(`[Lume.js state] Blocked write to reserved key "${key}"`);
274
+ return true;
275
+ }
276
+
214
277
  const oldValue = target[key];
215
278
 
216
279
  // Skip update if value unchanged - Object.is() handles NaN and -0 correctly
@@ -261,22 +324,15 @@ export function state(obj) {
261
324
  throw new Error('Subscriber must be a function');
262
325
  }
263
326
 
264
- if (!listeners[key]) listeners[key] = [];
265
- 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;
266
331
 
267
332
  // Call immediately with current value (NOT batched)
268
333
  fn(proxy[key]);
269
334
 
270
- // Return unsubscribe function
271
- return () => {
272
- if (listeners[key]) {
273
- const idx = listeners[key].indexOf(fn);
274
- if (idx !== -1) {
275
- listeners[key].splice(idx, 1);
276
- if (listeners[key].length === 0) delete listeners[key];
277
- }
278
- }
279
- };
335
+ return unsubscribe;
280
336
  };
281
337
 
282
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
+ }
@@ -5,12 +5,24 @@
5
5
  * @param {string} name - HTML attribute name (e.g., 'href', 'src', 'title')
6
6
  * @returns {{ attr: string, apply: function }}
7
7
  */
8
+
9
+ const DANGEROUS_SCHEME = /^(javascript|vbscript|data\s*:\s*text\/html)/i;
10
+ const URI_ATTRS = new Set(['href', 'src', 'action', 'srcset', 'poster', 'formaction']);
11
+
8
12
  export function stringAttr(name) {
9
13
  return {
10
14
  attr: `data-${name}`,
11
15
  apply(el, val) {
12
- if (val == null) el.removeAttribute(name);
13
- else el.setAttribute(name, String(val));
16
+ if (val == null) {
17
+ el.removeAttribute(name);
18
+ return;
19
+ }
20
+ const strVal = String(val);
21
+ if (URI_ATTRS.has(name) && DANGEROUS_SCHEME.test(strVal)) {
22
+ el.removeAttribute(name);
23
+ return;
24
+ }
25
+ el.setAttribute(name, strVal);
14
26
  }
15
27
  };
16
28
  }