lume-js 1.0.0 → 2.0.0-alpha.2
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 +10 -4
- package/package.json +7 -3
- package/src/addons/debug.js +330 -0
- package/src/addons/index.d.ts +98 -4
- package/src/addons/index.js +2 -1
- package/src/addons/repeat.js +75 -59
- package/src/core/bindDom.js +23 -11
- package/src/core/effect.js +91 -51
- package/src/core/state.js +104 -10
- package/src/index.d.ts +143 -2
package/src/addons/repeat.js
CHANGED
|
@@ -18,65 +18,58 @@
|
|
|
18
18
|
* - Set to null/false to disable, or provide custom functions
|
|
19
19
|
* - Export utilities so you can wrap/extend them
|
|
20
20
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
21
|
+
* ⚠️ IMPORTANT: Arrays must be updated immutably!
|
|
22
|
+
* store.items.push(x) // ❌ Won't trigger update
|
|
23
|
+
* store.items = [...items] // ✅ Triggers update
|
|
24
|
+
*
|
|
25
|
+
* ═══════════════════════════════════════════════════════════════════════
|
|
26
|
+
* PATTERN 1: Simple (render only) - for simple cases or backward compat
|
|
27
|
+
* ═══════════════════════════════════════════════════════════════════════
|
|
28
|
+
*
|
|
29
29
|
* repeat('#list', store, 'todos', {
|
|
30
30
|
* key: todo => todo.id,
|
|
31
31
|
* render: (todo, el) => {
|
|
32
|
-
*
|
|
33
|
-
* el.innerHTML = `<input value="${todo.text}">`;
|
|
34
|
-
* el.dataset.init = 'true';
|
|
35
|
-
* }
|
|
32
|
+
* el.textContent = todo.name; // Called on every update
|
|
36
33
|
* }
|
|
37
34
|
* });
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
* //
|
|
53
|
-
*
|
|
54
|
-
*
|
|
35
|
+
*
|
|
36
|
+
* ═══════════════════════════════════════════════════════════════════════
|
|
37
|
+
* PATTERN 2: Clean separation (create + update) - recommended
|
|
38
|
+
* ═══════════════════════════════════════════════════════════════════════
|
|
39
|
+
*
|
|
40
|
+
* repeat('#list', store, 'todos', {
|
|
41
|
+
* key: todo => todo.id,
|
|
42
|
+
* create: (todo, el) => {
|
|
43
|
+
* // Called ONCE when element is created - build DOM structure
|
|
44
|
+
* el.innerHTML = `<span class="name"></span><button>Delete</button>`;
|
|
45
|
+
* el.querySelector('button').onclick = () => deleteTodo(todo.id);
|
|
46
|
+
* },
|
|
47
|
+
* update: (todo, el, index, { isFirstRender }) => {
|
|
48
|
+
* // Called on every update - bind data
|
|
49
|
+
* // isFirstRender = true on initial render, false on subsequent
|
|
50
|
+
* // Skipped if same object reference (optimization)
|
|
51
|
+
* el.querySelector('.name').textContent = todo.name;
|
|
55
52
|
* }
|
|
56
53
|
* });
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
54
|
+
*
|
|
55
|
+
* ═══════════════════════════════════════════════════════════════════════
|
|
56
|
+
* ADVANCED: Custom preservation strategies
|
|
57
|
+
* ═══════════════════════════════════════════════════════════════════════
|
|
58
|
+
*
|
|
59
|
+
* import { defaultFocusPreservation, defaultScrollPreservation } from "lume-js/addons";
|
|
60
60
|
*
|
|
61
61
|
* repeat('#list', store, 'items', {
|
|
62
62
|
* key: item => item.id,
|
|
63
|
-
*
|
|
64
|
-
*
|
|
63
|
+
* create: (item, el) => { ... },
|
|
64
|
+
* update: (item, el) => { ... },
|
|
65
|
+
* preserveFocus: null, // disable focus preservation
|
|
65
66
|
* preserveScroll: (container, context) => {
|
|
66
|
-
* // wrap/extend built-in
|
|
67
67
|
* const restore = defaultScrollPreservation(container, context);
|
|
68
|
-
* return () => {
|
|
69
|
-
* restore();
|
|
70
|
-
* console.log('Scroll restored!');
|
|
71
|
-
* };
|
|
68
|
+
* return () => { restore(); console.log('Scroll restored!'); };
|
|
72
69
|
* }
|
|
73
70
|
* });
|
|
74
71
|
*/
|
|
75
72
|
|
|
76
|
-
// ============================================================================
|
|
77
|
-
// PRESERVATION UTILITIES (Exported for customization)
|
|
78
|
-
// ============================================================================
|
|
79
|
-
|
|
80
73
|
/**
|
|
81
74
|
* Default focus preservation strategy
|
|
82
75
|
* Saves activeElement and selection state before DOM updates
|
|
@@ -158,10 +151,6 @@ export function defaultScrollPreservation(container, context = {}) {
|
|
|
158
151
|
};
|
|
159
152
|
}
|
|
160
153
|
|
|
161
|
-
// ============================================================================
|
|
162
|
-
// MAIN REPEAT FUNCTION
|
|
163
|
-
// ============================================================================
|
|
164
|
-
|
|
165
154
|
/**
|
|
166
155
|
* Efficiently render a list with element reuse
|
|
167
156
|
*
|
|
@@ -170,7 +159,9 @@ export function defaultScrollPreservation(container, context = {}) {
|
|
|
170
159
|
* @param {string} arrayKey - Key in store containing the array
|
|
171
160
|
* @param {Object} options - Configuration
|
|
172
161
|
* @param {Function} options.key - Function to extract unique key: (item) => key
|
|
173
|
-
* @param {Function} options.render - Function to render item: (item, element, index) => void
|
|
162
|
+
* @param {Function} [options.render] - Function to render item (called for all items): (item, element, index) => void
|
|
163
|
+
* @param {Function} [options.create] - Function for new elements only: (item, element, index) => void
|
|
164
|
+
* @param {Function} [options.update] - Function for data binding: (item, element, index, { isFirstRender }) => void. Skipped if same item reference AND same index.
|
|
174
165
|
* @param {string|Function} [options.element='div'] - Element tag name or factory function
|
|
175
166
|
* @param {Function|null} [options.preserveFocus=defaultFocusPreservation] - Focus preservation strategy (null to disable)
|
|
176
167
|
* @param {Function|null} [options.preserveScroll=defaultScrollPreservation] - Scroll preservation strategy (null to disable)
|
|
@@ -180,6 +171,8 @@ export function repeat(container, store, arrayKey, options) {
|
|
|
180
171
|
const {
|
|
181
172
|
key,
|
|
182
173
|
render,
|
|
174
|
+
create,
|
|
175
|
+
update,
|
|
183
176
|
element = 'div',
|
|
184
177
|
preserveFocus = defaultFocusPreservation,
|
|
185
178
|
preserveScroll = defaultScrollPreservation
|
|
@@ -200,12 +193,16 @@ export function repeat(container, store, arrayKey, options) {
|
|
|
200
193
|
throw new Error('[Lume.js] repeat(): options.key must be a function');
|
|
201
194
|
}
|
|
202
195
|
|
|
203
|
-
if (typeof render !== 'function') {
|
|
204
|
-
throw new Error('[Lume.js] repeat(): options.render must be a function');
|
|
196
|
+
if (typeof render !== 'function' && typeof create !== 'function') {
|
|
197
|
+
throw new Error('[Lume.js] repeat(): options.render or options.create must be a function');
|
|
205
198
|
}
|
|
206
199
|
|
|
207
200
|
// key -> HTMLElement
|
|
208
201
|
const elementsByKey = new Map();
|
|
202
|
+
// key -> previous item (for reference comparison)
|
|
203
|
+
const prevItemsByKey = new Map();
|
|
204
|
+
// key -> previous index (for reorder detection)
|
|
205
|
+
const prevIndexByKey = new Map();
|
|
209
206
|
const seenKeys = new Set();
|
|
210
207
|
|
|
211
208
|
function createElement() {
|
|
@@ -254,24 +251,38 @@ export function repeat(container, store, arrayKey, options) {
|
|
|
254
251
|
nextKeys.add(k);
|
|
255
252
|
|
|
256
253
|
let el = elementsByKey.get(k);
|
|
257
|
-
const
|
|
254
|
+
const isFirstRender = !el;
|
|
258
255
|
|
|
259
|
-
if (
|
|
256
|
+
if (isFirstRender) {
|
|
260
257
|
el = createElement();
|
|
261
258
|
elementsByKey.set(k, el);
|
|
262
259
|
}
|
|
263
260
|
|
|
264
261
|
try {
|
|
265
|
-
|
|
266
|
-
|
|
262
|
+
// Call create for new elements (DOM structure)
|
|
263
|
+
if (isFirstRender && create) {
|
|
264
|
+
create(item, el, i);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Call update for data binding (new and existing elements)
|
|
268
|
+
// Skip if same item reference AND same index (optimization)
|
|
269
|
+
const prevItem = prevItemsByKey.get(k);
|
|
270
|
+
const prevIndex = prevIndexByKey.get(k);
|
|
271
|
+
if (update) {
|
|
272
|
+
if (prevItem !== item || prevIndex !== i) {
|
|
273
|
+
update(item, el, i, { isFirstRender });
|
|
274
|
+
}
|
|
275
|
+
} else if (render) {
|
|
276
|
+
// Backward compatibility: render handles both create and update
|
|
277
|
+
render(item, el, i);
|
|
267
278
|
}
|
|
268
279
|
|
|
269
|
-
|
|
280
|
+
// Store reference and index for next comparison
|
|
281
|
+
prevItemsByKey.set(k, item);
|
|
282
|
+
prevIndexByKey.set(k, i);
|
|
270
283
|
|
|
271
284
|
} catch (err) {
|
|
272
285
|
console.error(`[Lume.js] repeat(): error rendering key "${k}"`, err);
|
|
273
|
-
} finally {
|
|
274
|
-
delete el.__lume_new;
|
|
275
286
|
}
|
|
276
287
|
|
|
277
288
|
nextEls.push(el);
|
|
@@ -298,12 +309,13 @@ export function repeat(container, store, arrayKey, options) {
|
|
|
298
309
|
ptr = next;
|
|
299
310
|
}
|
|
300
311
|
|
|
301
|
-
// Clean
|
|
302
|
-
// Iterate over elementsByKey entries and delete if not in nextKeys
|
|
312
|
+
// Clean maps: remove keys not in nextKeys
|
|
303
313
|
if (elementsByKey.size !== nextKeys.size) {
|
|
304
314
|
for (const k of elementsByKey.keys()) {
|
|
305
315
|
if (!nextKeys.has(k)) {
|
|
306
316
|
elementsByKey.delete(k);
|
|
317
|
+
prevItemsByKey.delete(k);
|
|
318
|
+
prevIndexByKey.delete(k);
|
|
307
319
|
}
|
|
308
320
|
}
|
|
309
321
|
}
|
|
@@ -328,6 +340,8 @@ export function repeat(container, store, arrayKey, options) {
|
|
|
328
340
|
return () => {
|
|
329
341
|
containerEl.replaceChildren();
|
|
330
342
|
elementsByKey.clear();
|
|
343
|
+
prevItemsByKey.clear();
|
|
344
|
+
prevIndexByKey.clear();
|
|
331
345
|
seenKeys.clear();
|
|
332
346
|
};
|
|
333
347
|
}
|
|
@@ -337,6 +351,8 @@ export function repeat(container, store, arrayKey, options) {
|
|
|
337
351
|
// Clear DOM elements (replaceChildren is faster than loop)
|
|
338
352
|
containerEl.replaceChildren();
|
|
339
353
|
elementsByKey.clear();
|
|
354
|
+
prevItemsByKey.clear();
|
|
355
|
+
prevIndexByKey.clear();
|
|
340
356
|
seenKeys.clear();
|
|
341
357
|
};
|
|
342
358
|
}
|
package/src/core/bindDom.js
CHANGED
|
@@ -52,9 +52,12 @@ export function bindDom(root, store, options = {}) {
|
|
|
52
52
|
const nodes = root.querySelectorAll("[data-bind]");
|
|
53
53
|
const cleanups = [];
|
|
54
54
|
|
|
55
|
+
// Map for event delegation: element → { target, lastKey }
|
|
56
|
+
const bindingMap = new Map();
|
|
57
|
+
|
|
55
58
|
nodes.forEach(el => {
|
|
56
59
|
const bindPath = el.getAttribute("data-bind");
|
|
57
|
-
|
|
60
|
+
|
|
58
61
|
if (!bindPath) {
|
|
59
62
|
console.warn('[Lume.js] Empty data-bind attribute found', el);
|
|
60
63
|
return;
|
|
@@ -82,18 +85,27 @@ export function bindDom(root, store, options = {}) {
|
|
|
82
85
|
});
|
|
83
86
|
cleanups.push(unsubscribe);
|
|
84
87
|
|
|
85
|
-
//
|
|
88
|
+
// Store binding info for event delegation (form inputs only)
|
|
86
89
|
if (isFormInput(el)) {
|
|
87
|
-
|
|
88
|
-
target[lastKey] = getInputValue(e.target);
|
|
89
|
-
};
|
|
90
|
-
el.addEventListener("input", handler);
|
|
91
|
-
cleanups.push(() => el.removeEventListener("input", handler));
|
|
90
|
+
bindingMap.set(el, { target, lastKey });
|
|
92
91
|
}
|
|
93
92
|
});
|
|
94
93
|
|
|
94
|
+
// Event delegation: single listener on root for all form inputs
|
|
95
|
+
const delegatedHandler = e => {
|
|
96
|
+
const el = e.target;
|
|
97
|
+
const binding = bindingMap.get(el);
|
|
98
|
+
if (binding) {
|
|
99
|
+
binding.target[binding.lastKey] = getInputValue(el);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
root.addEventListener("input", delegatedHandler);
|
|
104
|
+
cleanups.push(() => root.removeEventListener("input", delegatedHandler));
|
|
105
|
+
|
|
95
106
|
return () => {
|
|
96
107
|
cleanups.forEach(cleanup => cleanup());
|
|
108
|
+
bindingMap.clear();
|
|
97
109
|
};
|
|
98
110
|
};
|
|
99
111
|
|
|
@@ -104,7 +116,7 @@ export function bindDom(root, store, options = {}) {
|
|
|
104
116
|
cleanup = performBinding();
|
|
105
117
|
};
|
|
106
118
|
document.addEventListener('DOMContentLoaded', onReady, { once: true });
|
|
107
|
-
|
|
119
|
+
|
|
108
120
|
// Return cleanup function that handles both cases
|
|
109
121
|
return () => {
|
|
110
122
|
if (cleanup) {
|
|
@@ -165,7 +177,7 @@ function getInputValue(el) {
|
|
|
165
177
|
* @private
|
|
166
178
|
*/
|
|
167
179
|
function isFormInput(el) {
|
|
168
|
-
return el.tagName === "INPUT" ||
|
|
169
|
-
|
|
170
|
-
|
|
180
|
+
return el.tagName === "INPUT" ||
|
|
181
|
+
el.tagName === "TEXTAREA" ||
|
|
182
|
+
el.tagName === "SELECT";
|
|
171
183
|
}
|
package/src/core/effect.js
CHANGED
|
@@ -1,100 +1,140 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Lume-JS Effect
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Reactive effects with two modes:
|
|
5
|
+
* 1. Auto-tracking (default): Tracks dependencies automatically
|
|
6
|
+
* 2. Explicit deps: You specify exactly what triggers re-runs
|
|
7
7
|
*
|
|
8
8
|
* Part of core because it's fundamental to modern reactivity.
|
|
9
9
|
*
|
|
10
10
|
* Usage:
|
|
11
11
|
* import { effect } from "lume-js";
|
|
12
12
|
*
|
|
13
|
+
* // Auto-tracking mode (existing behavior)
|
|
13
14
|
* effect(() => {
|
|
14
15
|
* console.log('Count is:', store.count);
|
|
15
16
|
* // Automatically re-runs when store.count changes
|
|
16
17
|
* });
|
|
18
|
+
*
|
|
19
|
+
* // Explicit deps mode (new - no magic)
|
|
20
|
+
* effect(() => {
|
|
21
|
+
* console.log('Count is:', store.count);
|
|
22
|
+
* }, [[store, 'count']]); // Only re-runs when store.count changes
|
|
17
23
|
*
|
|
18
24
|
* Features:
|
|
19
|
-
* - Automatic dependency collection
|
|
20
|
-
* -
|
|
25
|
+
* - Automatic dependency collection (default)
|
|
26
|
+
* - Explicit dependencies for side-effects
|
|
21
27
|
* - Returns cleanup function
|
|
22
|
-
* -
|
|
23
|
-
*
|
|
28
|
+
* - Compatible with per-state batching
|
|
24
29
|
*/
|
|
25
30
|
|
|
26
31
|
/**
|
|
27
|
-
* Creates an effect that
|
|
32
|
+
* Creates an effect that runs reactively
|
|
28
33
|
*
|
|
29
|
-
* The effect runs immediately and collects dependencies by tracking
|
|
30
|
-
* which state properties are accessed. When any dependency changes,
|
|
31
|
-
* the effect re-runs automatically.
|
|
32
|
-
*
|
|
33
34
|
* @param {function} fn - Function to run reactively
|
|
35
|
+
* @param {Array<[object, string]>} [deps] - Optional explicit dependencies as [store, key] tuples
|
|
34
36
|
* @returns {function} Cleanup function to stop the effect
|
|
35
37
|
*
|
|
36
38
|
* @example
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* // Only tracks 'count' (name not accessed)
|
|
39
|
+
* // Auto-tracking (default)
|
|
40
|
+
* const store = state({ count: 0 });
|
|
41
|
+
* effect(() => {
|
|
41
42
|
* document.title = `Count: ${store.count}`;
|
|
42
43
|
* });
|
|
43
44
|
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
45
|
+
* @example
|
|
46
|
+
* // Explicit deps (no magic)
|
|
47
|
+
* effect(() => {
|
|
48
|
+
* analytics.log(store.count); // Won't track store.count automatically
|
|
49
|
+
* }, [[store, 'count']]); // Explicit: only re-run on store.count
|
|
48
50
|
*/
|
|
49
|
-
export function effect(fn) {
|
|
51
|
+
export function effect(fn, deps) {
|
|
50
52
|
if (typeof fn !== 'function') {
|
|
51
53
|
throw new Error('effect() requires a function');
|
|
52
54
|
}
|
|
53
55
|
|
|
54
56
|
const cleanups = [];
|
|
55
|
-
let isRunning = false;
|
|
56
|
-
|
|
57
|
+
let isRunning = false;
|
|
58
|
+
|
|
57
59
|
/**
|
|
58
|
-
* Execute the effect function
|
|
59
|
-
*
|
|
60
|
-
* The execution re-tracks accessed keys on every run. Subscriptions
|
|
61
|
-
* are cleaned up and re-established so the effect always reflects
|
|
62
|
-
* current dependencies.
|
|
60
|
+
* Execute the effect function
|
|
63
61
|
*/
|
|
64
62
|
const execute = () => {
|
|
65
|
-
if (isRunning) return;
|
|
66
|
-
|
|
67
|
-
// Clean up previous subscriptions
|
|
68
|
-
cleanups.forEach(cleanup => cleanup());
|
|
69
|
-
cleanups.length = 0;
|
|
70
|
-
|
|
71
|
-
// Create effect context for tracking
|
|
72
|
-
const effectContext = {
|
|
73
|
-
fn,
|
|
74
|
-
cleanups,
|
|
75
|
-
execute, // Reference to this execute function
|
|
76
|
-
tracking: {} // Map of tracked keys
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
// Set as current effect (for state.js to detect)
|
|
80
|
-
globalThis.__LUME_CURRENT_EFFECT__ = effectContext;
|
|
63
|
+
if (isRunning) return;
|
|
81
64
|
isRunning = true;
|
|
82
|
-
|
|
65
|
+
|
|
83
66
|
try {
|
|
84
|
-
// Run the effect function (this triggers state getters)
|
|
85
67
|
fn();
|
|
86
68
|
} catch (error) {
|
|
87
69
|
console.error('[Lume.js effect] Error in effect:', error);
|
|
88
70
|
throw error;
|
|
89
71
|
} finally {
|
|
90
|
-
// Always clean up, even if error
|
|
91
|
-
globalThis.__LUME_CURRENT_EFFECT__ = undefined;
|
|
92
72
|
isRunning = false;
|
|
93
73
|
}
|
|
94
74
|
};
|
|
95
|
-
|
|
96
|
-
//
|
|
97
|
-
|
|
75
|
+
|
|
76
|
+
// EXPLICIT DEPS MODE: deps array provided
|
|
77
|
+
if (Array.isArray(deps)) {
|
|
78
|
+
// Subscribe to each [store, key1, key2, ...] tuple explicitly
|
|
79
|
+
for (const dep of deps) {
|
|
80
|
+
if (Array.isArray(dep) && dep.length >= 2) {
|
|
81
|
+
const [store, ...keys] = dep;
|
|
82
|
+
if (store && typeof store.$subscribe === 'function') {
|
|
83
|
+
// Subscribe to each key in this tuple
|
|
84
|
+
for (const key of keys) {
|
|
85
|
+
// $subscribe calls immediately, then on changes
|
|
86
|
+
// We want: call execute immediately once, then on changes
|
|
87
|
+
let isFirst = true;
|
|
88
|
+
const unsub = store.$subscribe(key, () => {
|
|
89
|
+
if (isFirst) {
|
|
90
|
+
isFirst = false;
|
|
91
|
+
return; // Skip first call, we'll run execute() below
|
|
92
|
+
}
|
|
93
|
+
execute();
|
|
94
|
+
});
|
|
95
|
+
cleanups.push(unsub);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// Run immediately
|
|
101
|
+
execute();
|
|
102
|
+
}
|
|
103
|
+
// AUTO-TRACKING MODE: no deps (existing behavior)
|
|
104
|
+
else {
|
|
105
|
+
const executeWithTracking = () => {
|
|
106
|
+
if (isRunning) return;
|
|
107
|
+
|
|
108
|
+
// Clean up previous subscriptions
|
|
109
|
+
cleanups.forEach(cleanup => cleanup());
|
|
110
|
+
cleanups.length = 0;
|
|
111
|
+
|
|
112
|
+
// Create effect context for tracking
|
|
113
|
+
const effectContext = {
|
|
114
|
+
fn,
|
|
115
|
+
cleanups,
|
|
116
|
+
execute: executeWithTracking,
|
|
117
|
+
tracking: {}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// Set as current effect (for state.js to detect)
|
|
121
|
+
globalThis.__LUME_CURRENT_EFFECT__ = effectContext;
|
|
122
|
+
isRunning = true;
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
fn();
|
|
126
|
+
} catch (error) {
|
|
127
|
+
console.error('[Lume.js effect] Error in effect:', error);
|
|
128
|
+
throw error;
|
|
129
|
+
} finally {
|
|
130
|
+
globalThis.__LUME_CURRENT_EFFECT__ = undefined;
|
|
131
|
+
isRunning = false;
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// Run immediately to collect initial dependencies
|
|
136
|
+
executeWithTracking();
|
|
137
|
+
}
|
|
98
138
|
|
|
99
139
|
// Return cleanup function
|
|
100
140
|
return () => {
|
package/src/core/state.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Provides minimal reactive state with standard JavaScript.
|
|
5
5
|
* Features automatic microtask batching for performance.
|
|
6
6
|
* Supports automatic dependency tracking for effects.
|
|
7
|
+
* Supports optional plugin system for extensibility (v2.0+).
|
|
7
8
|
*
|
|
8
9
|
* Features:
|
|
9
10
|
* - Lightweight and Go-style
|
|
@@ -12,47 +13,93 @@
|
|
|
12
13
|
* - Cleanup with unsubscribe
|
|
13
14
|
* - Per-state microtask batching for writes
|
|
14
15
|
* - Effect dependency tracking support (deduped per state flush)
|
|
16
|
+
* - Plugin system for custom behaviors (v2.0+)
|
|
15
17
|
*
|
|
16
18
|
* Usage:
|
|
17
19
|
* import { state } from "lume-js";
|
|
20
|
+
*
|
|
21
|
+
* // Basic usage (v1.x compatible)
|
|
18
22
|
* const store = state({ count: 0 });
|
|
19
23
|
* const unsub = store.$subscribe("count", val => console.log(val));
|
|
20
24
|
* unsub(); // cleanup
|
|
25
|
+
*
|
|
26
|
+
* // With plugins (v2.0+)
|
|
27
|
+
* const store = state(
|
|
28
|
+
* { count: 0 },
|
|
29
|
+
* { plugins: [debugPlugin] }
|
|
30
|
+
* );
|
|
21
31
|
*/
|
|
22
32
|
|
|
23
33
|
// Per-state batching – each state object maintains its own microtask flush.
|
|
24
34
|
// This keeps effects simple and aligned with Lume's minimal philosophy.
|
|
25
35
|
|
|
26
36
|
/**
|
|
27
|
-
* Creates a reactive state object.
|
|
37
|
+
* Creates a reactive state object with optional plugin support.
|
|
28
38
|
*
|
|
29
|
-
* @param {Object} obj - Initial state object
|
|
39
|
+
* @param {Object} obj - Initial state object (must be plain object)
|
|
40
|
+
* @param {Object} [options] - Optional configuration
|
|
41
|
+
* @param {Array<Plugin>} [options.plugins] - Array of plugins to apply
|
|
30
42
|
* @returns {Proxy} Reactive proxy with $subscribe method
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* // Basic usage
|
|
46
|
+
* const store = state({ count: 0 });
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* // With plugins
|
|
50
|
+
* const store = state(
|
|
51
|
+
* { count: 0 },
|
|
52
|
+
* {
|
|
53
|
+
* plugins: [
|
|
54
|
+
* {
|
|
55
|
+
* name: 'logger',
|
|
56
|
+
* onGet: (key, value) => {
|
|
57
|
+
* console.log(`GET ${key}:`, value);
|
|
58
|
+
* return value;
|
|
59
|
+
* }
|
|
60
|
+
* }
|
|
61
|
+
* ]
|
|
62
|
+
* }
|
|
63
|
+
* );
|
|
31
64
|
*/
|
|
32
65
|
// Internal symbol used to mark reactive proxies (non-enumerable via Proxy trap)
|
|
33
66
|
const REACTIVE_MARKER = Symbol('__LUME_REACTIVE__');
|
|
34
67
|
|
|
35
|
-
export function state(obj) {
|
|
68
|
+
export function state(obj, options = {}) {
|
|
36
69
|
// Validate input
|
|
37
70
|
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
|
|
38
71
|
throw new Error('state() requires a plain object');
|
|
39
72
|
}
|
|
40
73
|
|
|
74
|
+
// Extract plugins (minimal validation)
|
|
75
|
+
const plugins = options.plugins || [];
|
|
76
|
+
|
|
41
77
|
const listeners = {};
|
|
42
78
|
const pendingNotifications = new Map(); // Per-state pending changes
|
|
43
79
|
const pendingEffects = new Set(); // Dedupe effects per state
|
|
44
80
|
let flushScheduled = false;
|
|
45
81
|
|
|
82
|
+
// Call onInit hooks
|
|
83
|
+
for (const p of plugins) {
|
|
84
|
+
try {
|
|
85
|
+
p.onInit?.();
|
|
86
|
+
} catch (e) {
|
|
87
|
+
console.error(`[Lume.js] Plugin "${p.name}" error in onInit:`, e);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
46
91
|
/**
|
|
47
92
|
* Schedule a single microtask flush for this state object.
|
|
48
93
|
*
|
|
49
94
|
* Flush order per state:
|
|
50
|
-
* 1)
|
|
51
|
-
* 2)
|
|
95
|
+
* 1) Call plugin onNotify hooks for each changed key
|
|
96
|
+
* 2) Notify subscribers for changed keys (key → subscribers)
|
|
97
|
+
* 3) Run each queued effect exactly once (Set-based dedupe)
|
|
52
98
|
*
|
|
53
99
|
* Notes:
|
|
54
100
|
* - Batching is per state; effects that depend on multiple states
|
|
55
101
|
* may run once per state that changed (by design).
|
|
102
|
+
* - Plugin onNotify hooks run before subscribers (can observe before notification)
|
|
56
103
|
*/
|
|
57
104
|
function scheduleFlush() {
|
|
58
105
|
if (flushScheduled) return;
|
|
@@ -61,6 +108,17 @@ export function state(obj) {
|
|
|
61
108
|
queueMicrotask(() => {
|
|
62
109
|
flushScheduled = false;
|
|
63
110
|
|
|
111
|
+
// Plugin onNotify hooks (before subscribers)
|
|
112
|
+
for (const [key, value] of pendingNotifications) {
|
|
113
|
+
for (const p of plugins) {
|
|
114
|
+
try {
|
|
115
|
+
p.onNotify?.(key, value);
|
|
116
|
+
} catch (e) {
|
|
117
|
+
console.error(`[Lume.js] Plugin "${p.name}" error in onNotify:`, e);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
64
122
|
// Notify all subscribers of changed keys
|
|
65
123
|
// Snapshot listeners array to handle unsubscribes during iteration
|
|
66
124
|
for (const [key, value] of pendingNotifications) {
|
|
@@ -87,7 +145,21 @@ export function state(obj) {
|
|
|
87
145
|
if (typeof key === 'string' && key.startsWith('$')) {
|
|
88
146
|
return target[key];
|
|
89
147
|
}
|
|
90
|
-
|
|
148
|
+
|
|
149
|
+
// Get original value
|
|
150
|
+
let value = target[key];
|
|
151
|
+
|
|
152
|
+
// Plugin onGet hooks run first (effects track key, not value)
|
|
153
|
+
for (const p of plugins) {
|
|
154
|
+
try {
|
|
155
|
+
const r = p.onGet?.(key, value);
|
|
156
|
+
if (r !== undefined) value = r;
|
|
157
|
+
} catch (e) {
|
|
158
|
+
console.error(`[Lume.js] Plugin "${p.name}" error in onGet:`, e);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Effect tracking
|
|
91
163
|
// Check if we're inside an effect context
|
|
92
164
|
if (typeof globalThis.__LUME_CURRENT_EFFECT__ !== 'undefined') {
|
|
93
165
|
const currentEffect = globalThis.__LUME_CURRENT_EFFECT__;
|
|
@@ -121,17 +193,30 @@ export function state(obj) {
|
|
|
121
193
|
}
|
|
122
194
|
}
|
|
123
195
|
|
|
124
|
-
return
|
|
196
|
+
return value;
|
|
125
197
|
},
|
|
126
198
|
|
|
127
199
|
set(target, key, value) {
|
|
128
200
|
const oldValue = target[key];
|
|
129
|
-
|
|
201
|
+
let newValue = value;
|
|
130
202
|
|
|
131
|
-
|
|
203
|
+
// Plugin onSet hooks (chain pattern)
|
|
204
|
+
for (const p of plugins) {
|
|
205
|
+
try {
|
|
206
|
+
const r = p.onSet?.(key, newValue, oldValue);
|
|
207
|
+
if (r !== undefined) newValue = r;
|
|
208
|
+
} catch (e) {
|
|
209
|
+
console.error(`[Lume.js] Plugin "${p.name}" error in onSet:`, e);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Skip update if value unchanged after plugin processing
|
|
214
|
+
if (oldValue === newValue) return true;
|
|
215
|
+
|
|
216
|
+
target[key] = newValue;
|
|
132
217
|
|
|
133
218
|
// Batch notifications at the state level (per-state, not global)
|
|
134
|
-
pendingNotifications.set(key,
|
|
219
|
+
pendingNotifications.set(key, newValue);
|
|
135
220
|
scheduleFlush();
|
|
136
221
|
|
|
137
222
|
return true;
|
|
@@ -152,6 +237,15 @@ export function state(obj) {
|
|
|
152
237
|
throw new Error('Subscriber must be a function');
|
|
153
238
|
}
|
|
154
239
|
|
|
240
|
+
// Plugin onSubscribe hooks
|
|
241
|
+
for (const p of plugins) {
|
|
242
|
+
try {
|
|
243
|
+
p.onSubscribe?.(key);
|
|
244
|
+
} catch (e) {
|
|
245
|
+
console.error(`[Lume.js] Plugin "${p.name}" error in onSubscribe:`, e);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
155
249
|
if (!listeners[key]) listeners[key] = [];
|
|
156
250
|
listeners[key].push(fn);
|
|
157
251
|
|