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.
- package/README.md +90 -19
- package/dist/addons.min.mjs +1 -1
- package/dist/addons.mjs +160 -6
- package/dist/addons.mjs.map +1 -1
- package/dist/handlers.min.mjs +1 -1
- package/dist/handlers.mjs +38 -2
- package/dist/handlers.mjs.map +1 -1
- package/dist/index.min.mjs +1 -1
- package/dist/index.mjs +4 -141
- package/dist/index.mjs.map +1 -1
- package/dist/lume.global.js +1 -1
- package/dist/lume.global.js.map +1 -1
- package/dist/shared-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/computed.js +5 -0
- package/src/addons/hydrateState.js +32 -4
- 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/addons/withPlugins.js +7 -1
- package/src/core/batch.js +139 -0
- package/src/core/bindDom.js +12 -3
- package/src/core/effect.js +34 -5
- package/src/core/state.js +124 -68
- package/src/handlers/index.d.ts +24 -0
- package/src/handlers/index.js +1 -0
- package/src/handlers/on.js +60 -0
- package/src/handlers/stringAttr.js +14 -2
- 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-Dcokqj5a.mjs +0 -249
- 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
|
+
}
|
package/src/core/bindDom.js
CHANGED
|
@@ -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 =>
|
|
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
|
|
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);
|
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:
|
|
@@ -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 <
|
|
181
|
+
while ((pendingNotifications.size > 0 || pendingEffects.size > 0) && iterations < MAX_FLUSH_ITERATIONS) {
|
|
110
182
|
iterations++;
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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 >=
|
|
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
|
-
//
|
|
170
|
-
|
|
171
|
-
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 });
|
|
172
210
|
|
|
173
|
-
|
|
174
|
-
const
|
|
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(
|
|
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
|
-
|
|
265
|
-
|
|
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
|
-
|
|
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;
|
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
|
+
}
|
|
@@ -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)
|
|
13
|
-
|
|
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
|
}
|