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.
- package/README.md +70 -10
- package/dist/addons.min.mjs +1 -1
- package/dist/addons.mjs +149 -4
- package/dist/addons.mjs.map +1 -1
- package/dist/handlers.min.mjs +1 -1
- package/dist/handlers.mjs +26 -0
- 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-Bk_gndPJ.mjs +232 -0
- package/dist/shared-Bk_gndPJ.mjs.map +1 -0
- package/dist/shared-DNe4ez8V.mjs +249 -0
- package/dist/shared-DNe4ez8V.mjs.map +1 -0
- package/dist/shared-DmpHYKx7.mjs +15 -0
- package/dist/shared-DmpHYKx7.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/package.json +5 -1
- package/src/addons/index.d.ts +70 -2
- package/src/addons/index.js +13 -2
- package/src/addons/persist.js +152 -0
- package/src/addons/repeat.js +120 -5
- 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 +110 -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/index.d.ts +40 -0
- package/src/index.js +3 -1
- package/src/state.d.ts +17 -0
- package/src/state.js +25 -0
- package/dist/shared-x2HJmEyO.mjs +0 -260
- package/dist/shared-x2HJmEyO.mjs.map +0 -1
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);
|
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 <
|
|
181
|
+
while ((pendingNotifications.size > 0 || pendingEffects.size > 0) && iterations < MAX_FLUSH_ITERATIONS) {
|
|
116
182
|
iterations++;
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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 >=
|
|
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
|
-
//
|
|
176
|
-
|
|
177
|
-
obj
|
|
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
|
-
|
|
180
|
-
const
|
|
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(
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
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;
|
package/src/handlers/index.d.ts
CHANGED
|
@@ -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
|
|
package/src/handlers/index.js
CHANGED
|
@@ -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";
|