lume-js 2.2.1 → 2.3.1
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/AGENT_GUIDE.md +224 -0
- package/README.md +97 -16
- package/dist/addons.min.mjs +1 -1
- package/dist/addons.mjs +190 -7
- package/dist/addons.mjs.map +1 -1
- package/dist/handlers.min.mjs +1 -1
- package/dist/handlers.mjs +29 -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-BGg9PbiG.mjs +249 -0
- package/dist/shared-BGg9PbiG.mjs.map +1 -0
- package/dist/shared-DmpHYKx7.mjs +15 -0
- package/dist/shared-DmpHYKx7.mjs.map +1 -0
- package/dist/shared-SUXdsYBx.mjs +233 -0
- package/dist/shared-SUXdsYBx.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/llms-full.txt +6999 -0
- package/llms.txt +89 -0
- package/package.json +11 -2
- package/src/addons/index.d.ts +99 -7
- package/src/addons/index.js +13 -2
- package/src/addons/persist.js +190 -0
- package/src/addons/repeat.js +159 -10
- 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 +118 -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/handlers/stringAttr.js +9 -2
- package/src/index.d.ts +14 -200
- package/src/index.js +3 -1
- package/src/state.d.ts +252 -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/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,57 @@ 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
|
+
// Drain BEFORE delivering. Iterating the live Map is wrong twice over:
|
|
125
|
+
// a subscriber writing back to an already-delivered key would update
|
|
126
|
+
// the in-flight entry (never revisited) and the trailing clear() would
|
|
127
|
+
// destroy it — a silently lost update; and a subscriber opening a
|
|
128
|
+
// batch() would re-deliver every in-flight entry through the wave's
|
|
129
|
+
// re-entrant call. Draining first means write-backs land in the now-
|
|
130
|
+
// empty live Map and are delivered by the next flush iteration/wave.
|
|
131
|
+
const entries = Array.from(pendingNotifications);
|
|
132
|
+
pendingNotifications.clear();
|
|
133
|
+
for (const [key, value] of entries) {
|
|
134
|
+
if (listeners[key]) {
|
|
135
|
+
const subs = listeners[key];
|
|
136
|
+
let i = 0;
|
|
137
|
+
while (i < subs.length) {
|
|
138
|
+
const fn = subs[i];
|
|
139
|
+
try {
|
|
140
|
+
fn(value);
|
|
141
|
+
} catch (err) {
|
|
142
|
+
logError(`[Lume.js state] Error notifying subscriber for key "${String(key)}":`, err);
|
|
143
|
+
}
|
|
144
|
+
// Only advance if fn wasn't removed (something shifted into its place)
|
|
145
|
+
if (subs[i] === fn) i++;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Drain queued effects (Set deduplicates) into an array. */
|
|
152
|
+
function takeEffects() {
|
|
153
|
+
const effects = Array.from(pendingEffects);
|
|
154
|
+
pendingEffects.clear();
|
|
155
|
+
return effects;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Handle this state gives batch() — flush steps only, no live queues.
|
|
159
|
+
const batchHandle = { runBeforeFlushHooks, notifySubscribers, takeEffects };
|
|
160
|
+
|
|
92
161
|
/**
|
|
93
162
|
* Schedule a single microtask flush for this state object.
|
|
94
163
|
*
|
|
@@ -100,57 +169,28 @@ export function state(obj) {
|
|
|
100
169
|
*
|
|
101
170
|
* Notes:
|
|
102
171
|
* - Batching is per state; effects that depend on multiple states
|
|
103
|
-
* may run once per state that changed (by design).
|
|
172
|
+
* may run once per state that changed (by design). Use batch() to
|
|
173
|
+
* group writes across states and run such effects once.
|
|
174
|
+
* - Inside batch(), the microtask is skipped: the state enqueues
|
|
175
|
+
* itself for the synchronous flush at the end of the batch.
|
|
104
176
|
*/
|
|
105
177
|
function scheduleFlush() {
|
|
178
|
+
// Inside batch(): the batch captures this state's flush handle and
|
|
179
|
+
// flushes synchronously at the end — skip the microtask.
|
|
180
|
+
if (enqueueIfBatching(batchHandle)) return;
|
|
181
|
+
|
|
106
182
|
if (flushScheduled) return;
|
|
107
183
|
|
|
108
184
|
flushScheduled = true;
|
|
109
|
-
// eslint-disable-next-line sonarjs/cognitive-complexity -- single-pass flush loop: hooks → subscribers → effects → cycle detection; must stay atomic
|
|
110
185
|
queueMicrotask(() => {
|
|
111
186
|
let iterations = 0;
|
|
112
|
-
const MAX_ITERATIONS = 100;
|
|
113
187
|
|
|
114
188
|
try {
|
|
115
|
-
while ((pendingNotifications.size > 0 || pendingEffects.size > 0) && iterations <
|
|
189
|
+
while ((pendingNotifications.size > 0 || pendingEffects.size > 0) && iterations < MAX_FLUSH_ITERATIONS) {
|
|
116
190
|
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();
|
|
191
|
+
runBeforeFlushHooks();
|
|
192
|
+
notifySubscribers();
|
|
193
|
+
const effects = takeEffects();
|
|
154
194
|
for (let i = 0; i < effects.length; i++) {
|
|
155
195
|
try {
|
|
156
196
|
effects[i]();
|
|
@@ -163,7 +203,7 @@ export function state(obj) {
|
|
|
163
203
|
flushScheduled = false;
|
|
164
204
|
}
|
|
165
205
|
|
|
166
|
-
if (iterations >=
|
|
206
|
+
if (iterations >= MAX_FLUSH_ITERATIONS) {
|
|
167
207
|
logError(
|
|
168
208
|
'[Lume.js state] Maximum flush iterations reached (100). ' +
|
|
169
209
|
'This usually indicates an infinite loop caused by an effect or computed mutating state it depends on.'
|
|
@@ -172,29 +212,47 @@ export function state(obj) {
|
|
|
172
212
|
});
|
|
173
213
|
}
|
|
174
214
|
|
|
175
|
-
//
|
|
176
|
-
|
|
177
|
-
obj
|
|
215
|
+
// Stamp the shared brand (non-enumerable: spreads/Object.assign copies
|
|
216
|
+
// of a store do not inherit the brand and won't masquerade as reactive).
|
|
217
|
+
Object.defineProperty(obj, REACTIVE_BRAND, { value: true });
|
|
178
218
|
|
|
179
|
-
|
|
180
|
-
const
|
|
181
|
-
if (!listeners[key]) listeners[key] = [];
|
|
182
|
-
|
|
183
|
-
const callback = () => {
|
|
184
|
-
pendingEffects.add(executeFn);
|
|
185
|
-
};
|
|
186
|
-
|
|
187
|
-
listeners[key].push(callback);
|
|
219
|
+
const MAX_SUBSCRIBERS = 1000;
|
|
220
|
+
const noopUnsubscribe = () => {};
|
|
188
221
|
|
|
222
|
+
/**
|
|
223
|
+
* Shared listener registration with a per-key cap (subscriber DoS
|
|
224
|
+
* protection). Applied identically to $subscribe callbacks and effect
|
|
225
|
+
* subscriptions so both paths degrade the same way: a loud console
|
|
226
|
+
* error and a no-op unsubscribe.
|
|
227
|
+
*/
|
|
228
|
+
function addListener(key, fn, kind) {
|
|
229
|
+
if (!listeners[key]) listeners[key] = [];
|
|
230
|
+
if (listeners[key].length >= MAX_SUBSCRIBERS) {
|
|
231
|
+
logError(
|
|
232
|
+
`[Lume.js state] Subscriber limit (${MAX_SUBSCRIBERS}) reached for key "${String(key)}". ` +
|
|
233
|
+
`${kind} ignored — it will NOT receive updates. ` +
|
|
234
|
+
'This usually means subscriptions are created in a loop without cleanup.'
|
|
235
|
+
);
|
|
236
|
+
return noopUnsubscribe;
|
|
237
|
+
}
|
|
238
|
+
listeners[key].push(fn);
|
|
189
239
|
return () => {
|
|
190
240
|
if (listeners[key]) {
|
|
191
|
-
const idx = listeners[key].indexOf(
|
|
241
|
+
const idx = listeners[key].indexOf(fn);
|
|
192
242
|
if (idx !== -1) {
|
|
193
243
|
listeners[key].splice(idx, 1);
|
|
194
244
|
if (listeners[key].length === 0) delete listeners[key];
|
|
195
245
|
}
|
|
196
246
|
}
|
|
197
247
|
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Defined once per state instance — not per property read — to avoid per-read closure allocation.
|
|
251
|
+
const registerEffect = (key, executeFn) => {
|
|
252
|
+
const callback = () => {
|
|
253
|
+
pendingEffects.add(executeFn);
|
|
254
|
+
};
|
|
255
|
+
return addListener(key, callback, 'Effect subscription');
|
|
198
256
|
};
|
|
199
257
|
|
|
200
258
|
const BLOCKED_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
@@ -269,33 +327,20 @@ export function state(obj) {
|
|
|
269
327
|
};
|
|
270
328
|
};
|
|
271
329
|
|
|
272
|
-
const MAX_SUBSCRIBERS = 1000;
|
|
273
|
-
|
|
274
330
|
obj.$subscribe = (key, fn) => {
|
|
275
331
|
if (typeof fn !== 'function') {
|
|
276
332
|
throw new Error('Subscriber must be a function');
|
|
277
333
|
}
|
|
278
334
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
}
|
|
284
|
-
listeners[key].push(fn);
|
|
335
|
+
const unsubscribe = addListener(key, fn, 'New subscriber');
|
|
336
|
+
|
|
337
|
+
// Over the cap: listener was not added, skip the immediate call too
|
|
338
|
+
if (unsubscribe === noopUnsubscribe) return unsubscribe;
|
|
285
339
|
|
|
286
340
|
// Call immediately with current value (NOT batched)
|
|
287
341
|
fn(proxy[key]);
|
|
288
342
|
|
|
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
|
-
};
|
|
343
|
+
return unsubscribe;
|
|
299
344
|
};
|
|
300
345
|
|
|
301
346
|
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
|
+
}
|
|
@@ -6,7 +6,13 @@
|
|
|
6
6
|
* @returns {{ attr: string, apply: function }}
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
// Matched against a normalized copy of the value (C0 controls, space, and
|
|
10
|
+
// DEL stripped; lowercased) because the browser URL parser ignores those
|
|
11
|
+
// characters when determining the scheme — "java\tscript:alert(1)" and
|
|
12
|
+
// " javascript:alert(1)" both execute. The colon is required so legitimate
|
|
13
|
+
// relative URLs like "javascript-tutorial.html" are not blocked.
|
|
14
|
+
const DANGEROUS_SCHEME = /^(?:javascript:|vbscript:|data:text\/html)/;
|
|
15
|
+
const IGNORED_URL_CHARS = /[\u0000-\u0020\u007F]/g;
|
|
10
16
|
const URI_ATTRS = new Set(['href', 'src', 'action', 'srcset', 'poster', 'formaction']);
|
|
11
17
|
|
|
12
18
|
export function stringAttr(name) {
|
|
@@ -18,7 +24,8 @@ export function stringAttr(name) {
|
|
|
18
24
|
return;
|
|
19
25
|
}
|
|
20
26
|
const strVal = String(val);
|
|
21
|
-
if (URI_ATTRS.has(name) &&
|
|
27
|
+
if (URI_ATTRS.has(name) &&
|
|
28
|
+
DANGEROUS_SCHEME.test(strVal.replace(IGNORED_URL_CHARS, '').toLowerCase())) {
|
|
22
29
|
el.removeAttribute(name);
|
|
23
30
|
return;
|
|
24
31
|
}
|
package/src/index.d.ts
CHANGED
|
@@ -4,186 +4,23 @@
|
|
|
4
4
|
* Provides type safety for reactive state management
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Subscriber callback function
|
|
14
|
-
*/
|
|
15
|
-
export type Subscriber<T> = (value: T) => void;
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Internal unique symbol for reactive state branding
|
|
19
|
-
* @internal
|
|
20
|
-
*/
|
|
21
|
-
declare const lumeReactiveSymbol: unique symbol;
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Plugin interface for extending state behavior
|
|
25
|
-
*
|
|
26
|
-
* All hooks are optional. Hooks execute in the order plugins are registered.
|
|
27
|
-
*
|
|
28
|
-
* @example
|
|
29
|
-
* ```typescript
|
|
30
|
-
* const debugPlugin: Plugin = {
|
|
31
|
-
* name: 'debug',
|
|
32
|
-
* onGet: (key, value) => {
|
|
33
|
-
* console.log(`GET ${key}:`, value);
|
|
34
|
-
* return value; // Return value to pass to next plugin
|
|
35
|
-
* },
|
|
36
|
-
* onSet: (key, newValue, oldValue) => {
|
|
37
|
-
* console.log(`SET ${key}:`, oldValue, '→', newValue);
|
|
38
|
-
* return newValue; // Return value to pass to next plugin
|
|
39
|
-
* }
|
|
40
|
-
* };
|
|
41
|
-
*
|
|
42
|
-
* const store = withPlugins(state({ count: 0 }), [debugPlugin]);
|
|
43
|
-
* ```
|
|
44
|
-
*/
|
|
45
|
-
export interface Plugin {
|
|
46
|
-
/**
|
|
47
|
-
* Plugin name (for debugging)
|
|
48
|
-
*/
|
|
49
|
-
name: string;
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Called when state object is created
|
|
53
|
-
* Runs synchronously before Proxy is returned
|
|
54
|
-
*/
|
|
55
|
-
onInit?(): void;
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Called when a property is accessed (before value returned)
|
|
59
|
-
* Can transform the value by returning a new value
|
|
60
|
-
*
|
|
61
|
-
* Chain pattern: Each plugin receives the output of the previous plugin
|
|
62
|
-
*
|
|
63
|
-
* @param key - Property key being accessed
|
|
64
|
-
* @param value - Current value (possibly transformed by previous plugins)
|
|
65
|
-
* @returns Transformed value, or undefined to keep current value
|
|
66
|
-
*/
|
|
67
|
-
onGet?(key: string, value: any): any;
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Called when a property is updated (before subscribers notified)
|
|
71
|
-
* Can transform or validate the new value
|
|
72
|
-
*
|
|
73
|
-
* Chain pattern: Each plugin receives the output of the previous plugin
|
|
74
|
-
*
|
|
75
|
-
* @param key - Property key being updated
|
|
76
|
-
* @param newValue - New value being set (possibly transformed by previous plugins)
|
|
77
|
-
* @param oldValue - Previous value
|
|
78
|
-
* @returns Transformed value, or undefined to keep current value
|
|
79
|
-
*/
|
|
80
|
-
onSet?(key: string, newValue: any, oldValue: any): any;
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Called when a subscriber is added
|
|
84
|
-
* Useful for tracking active subscriptions
|
|
85
|
-
*
|
|
86
|
-
* @param key - Property key being subscribed to
|
|
87
|
-
*/
|
|
88
|
-
onSubscribe?(key: string): void;
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Called when subscribers are about to be notified
|
|
92
|
-
* Runs in microtask, before subscribers receive value
|
|
93
|
-
*
|
|
94
|
-
* @param key - Property key that changed
|
|
95
|
-
* @param value - New value being notified
|
|
96
|
-
*/
|
|
97
|
-
onNotify?(key: string, value: any): void;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Type-safe plugin interface for when you know the state shape.
|
|
102
|
-
* Provides better intellisense for key names and value types.
|
|
103
|
-
*
|
|
104
|
-
* @example
|
|
105
|
-
* ```typescript
|
|
106
|
-
* interface AppState {
|
|
107
|
-
* count: number;
|
|
108
|
-
* name: string;
|
|
109
|
-
* }
|
|
110
|
-
*
|
|
111
|
-
* const myPlugin: TypedPlugin<AppState> = {
|
|
112
|
-
* name: 'typed',
|
|
113
|
-
* onSet: (key, newValue, oldValue) => {
|
|
114
|
-
* // key is 'count' | 'name', values are properly typed
|
|
115
|
-
* return newValue;
|
|
116
|
-
* }
|
|
117
|
-
* };
|
|
118
|
-
* ```
|
|
119
|
-
*/
|
|
120
|
-
export interface TypedPlugin<T extends object> {
|
|
121
|
-
name: string;
|
|
122
|
-
onInit?(): void;
|
|
123
|
-
onGet?<K extends keyof T>(key: K, value: T[K]): T[K] | undefined;
|
|
124
|
-
onSet?<K extends keyof T>(key: K, newValue: T[K], oldValue: T[K]): T[K] | undefined;
|
|
125
|
-
onSubscribe?<K extends keyof T>(key: K): void;
|
|
126
|
-
onNotify?<K extends keyof T>(key: K, value: T[K]): void;
|
|
127
|
-
}
|
|
7
|
+
// ── Kernel types & functions ─────────────────────────────────────────────
|
|
8
|
+
// The DOM-free kernel (state, batch, withReadObserver and their types)
|
|
9
|
+
// lives in ./state.d.ts — the source of truth for the `lume-js/state`
|
|
10
|
+
// entry, which must type-check without lib.dom (Node/worker consumers).
|
|
11
|
+
// This entry re-exports the kernel and adds the DOM-flavored API.
|
|
128
12
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
* @param callback - Function called when property changes
|
|
137
|
-
* @returns Unsubscribe function for cleanup
|
|
138
|
-
*/
|
|
139
|
-
$subscribe<K extends keyof T>(
|
|
140
|
-
key: K,
|
|
141
|
-
callback: Subscriber<T[K]>
|
|
142
|
-
): Unsubscribe;
|
|
13
|
+
export type {
|
|
14
|
+
Unsubscribe,
|
|
15
|
+
Subscriber,
|
|
16
|
+
Plugin,
|
|
17
|
+
TypedPlugin,
|
|
18
|
+
ReactiveState,
|
|
19
|
+
} from './state.js';
|
|
143
20
|
|
|
144
|
-
|
|
145
|
-
* Register a callback to run before each flush.
|
|
146
|
-
* Dedupes duplicate function references.
|
|
147
|
-
* @param fn - Callback function
|
|
148
|
-
* @returns Unsubscribe function for cleanup
|
|
149
|
-
*/
|
|
150
|
-
$beforeFlush(fn: () => void): Unsubscribe;
|
|
21
|
+
export { state, batch, withReadObserver } from './state.js';
|
|
151
22
|
|
|
152
|
-
|
|
153
|
-
* Brand to identify reactive state objects at the type level
|
|
154
|
-
* @internal
|
|
155
|
-
*/
|
|
156
|
-
readonly [lumeReactiveSymbol]?: true;
|
|
157
|
-
};
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* Create a reactive state object
|
|
161
|
-
*
|
|
162
|
-
* @param obj - Plain object to make reactive
|
|
163
|
-
* @returns Reactive proxy with $subscribe method
|
|
164
|
-
* @throws {Error} If obj is not a plain object
|
|
165
|
-
*
|
|
166
|
-
* @example
|
|
167
|
-
* ```typescript
|
|
168
|
-
* const store = state({
|
|
169
|
-
* count: 0,
|
|
170
|
-
* user: state({
|
|
171
|
-
* name: 'Alice'
|
|
172
|
-
* })
|
|
173
|
-
* });
|
|
174
|
-
*
|
|
175
|
-
* store.count++; // Triggers reactivity
|
|
176
|
-
*
|
|
177
|
-
* const unsub = store.$subscribe('count', (val) => {
|
|
178
|
-
* console.log('Count:', val);
|
|
179
|
-
* });
|
|
180
|
-
*
|
|
181
|
-
* // Cleanup
|
|
182
|
-
* unsub();
|
|
183
|
-
* ```
|
|
184
|
-
*
|
|
185
|
-
*/
|
|
186
|
-
export function state<T extends object>(obj: T): ReactiveState<T>;
|
|
23
|
+
import type { ReactiveState, Unsubscribe } from './state.js';
|
|
187
24
|
|
|
188
25
|
/**
|
|
189
26
|
* Handler interface for extending bindDom with custom reactive data-* attributes.
|
|
@@ -353,29 +190,6 @@ export function effect(fn: () => void): Unsubscribe;
|
|
|
353
190
|
*/
|
|
354
191
|
export function effect(fn: () => void, deps: EffectDependency[]): Unsubscribe;
|
|
355
192
|
|
|
356
|
-
/**
|
|
357
|
-
* Run a function with a read observer active.
|
|
358
|
-
*
|
|
359
|
-
* The observer receives `(proxy, key, registerEffect)` for every property read
|
|
360
|
-
* during the synchronous execution of `fn`. Used internally by `effect()` for
|
|
361
|
-
* auto-tracking, and exposed for building custom reactive primitives.
|
|
362
|
-
*
|
|
363
|
-
* @param onRead - Called on each property access inside fn
|
|
364
|
-
* @param fn - The function to run under observation
|
|
365
|
-
* @returns The return value of fn
|
|
366
|
-
*
|
|
367
|
-
* @internal
|
|
368
|
-
*/
|
|
369
|
-
export function withReadObserver<T>(
|
|
370
|
-
onRead: (
|
|
371
|
-
proxy: ReactiveState<any>,
|
|
372
|
-
key: string,
|
|
373
|
-
registerEffect: (key: string, executeFn: () => void) => () => void
|
|
374
|
-
) => void,
|
|
375
|
-
fn: () => T
|
|
376
|
-
): T;
|
|
377
|
-
|
|
378
|
-
|
|
379
193
|
// ============================================================================
|
|
380
194
|
// Utility Types
|
|
381
195
|
// ============================================================================
|
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";
|