lume-js 0.3.0 → 0.4.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/README.md +426 -39
- package/package.json +20 -3
- package/src/addons/computed.js +116 -33
- package/src/addons/index.d.ts +85 -0
- package/src/core/bindDom.js +81 -38
- package/src/core/effect.js +104 -0
- package/src/core/state.js +105 -11
- package/src/index.d.ts +56 -2
- package/src/index.js +4 -2
package/src/addons/computed.js
CHANGED
|
@@ -1,53 +1,136 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Lume-JS Computed Addon
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Creates computed values that automatically update when dependencies change.
|
|
5
|
+
* Uses core effect() for automatic dependency tracking.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* import { computed } from "lume-js/addons/computed";
|
|
9
|
+
*
|
|
10
|
+
* const doubled = computed(() => store.count * 2);
|
|
11
|
+
* console.log(doubled.value); // Auto-updates when store.count changes
|
|
12
|
+
*
|
|
13
|
+
* Features:
|
|
14
|
+
* - Automatic dependency tracking (no manual recompute)
|
|
15
|
+
* - Cached values (only recomputes when dependencies change)
|
|
16
|
+
* - Subscribe to changes
|
|
17
|
+
* - Cleanup with dispose()
|
|
18
|
+
*
|
|
19
|
+
* @module addons/computed
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { effect } from '../core/effect.js';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Creates a computed value with automatic dependency tracking
|
|
26
|
+
*
|
|
27
|
+
* The computation function runs immediately and tracks which state
|
|
28
|
+
* properties are accessed. When any dependency changes, the value
|
|
29
|
+
* is automatically recomputed.
|
|
30
|
+
*
|
|
31
|
+
* @param {function} fn - Function that computes the value
|
|
32
|
+
* @returns {object} Object with .value property and methods
|
|
9
33
|
*
|
|
10
34
|
* @example
|
|
11
|
-
* const store = state({ count:
|
|
35
|
+
* const store = state({ count: 5 });
|
|
36
|
+
*
|
|
12
37
|
* const doubled = computed(() => store.count * 2);
|
|
38
|
+
* console.log(doubled.value); // 10
|
|
39
|
+
*
|
|
40
|
+
* store.count = 10;
|
|
41
|
+
* // After microtask:
|
|
42
|
+
* console.log(doubled.value); // 20 (auto-updated)
|
|
13
43
|
*
|
|
44
|
+
* @example
|
|
14
45
|
* // Subscribe to changes
|
|
15
|
-
* doubled.subscribe(
|
|
46
|
+
* const unsub = doubled.subscribe(value => {
|
|
47
|
+
* console.log('Doubled changed to:', value);
|
|
48
|
+
* });
|
|
16
49
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
50
|
+
* @example
|
|
51
|
+
* // Cleanup
|
|
52
|
+
* doubled.dispose();
|
|
19
53
|
*/
|
|
20
54
|
export function computed(fn) {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
55
|
+
if (typeof fn !== 'function') {
|
|
56
|
+
throw new Error('computed() requires a function');
|
|
57
|
+
}
|
|
24
58
|
|
|
25
|
-
|
|
59
|
+
let cachedValue;
|
|
60
|
+
let isInitialized = false;
|
|
61
|
+
const subscribers = [];
|
|
62
|
+
|
|
63
|
+
// Use effect to automatically track dependencies
|
|
64
|
+
const cleanupEffect = effect(() => {
|
|
26
65
|
try {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
66
|
+
const newValue = fn();
|
|
67
|
+
|
|
68
|
+
// Check if value actually changed
|
|
69
|
+
if (!isInitialized || newValue !== cachedValue) {
|
|
70
|
+
cachedValue = newValue;
|
|
71
|
+
isInitialized = true;
|
|
72
|
+
|
|
73
|
+
// Notify all subscribers
|
|
74
|
+
subscribers.forEach(callback => callback(cachedValue));
|
|
75
|
+
}
|
|
76
|
+
} catch (error) {
|
|
77
|
+
console.error('[Lume.js computed] Error in computation:', error);
|
|
78
|
+
// Set to undefined on error, mark as initialized
|
|
79
|
+
if (!isInitialized || cachedValue !== undefined) {
|
|
80
|
+
cachedValue = undefined;
|
|
81
|
+
isInitialized = true;
|
|
82
|
+
|
|
83
|
+
// Notify subscribers of error state
|
|
84
|
+
subscribers.forEach(callback => callback(cachedValue));
|
|
85
|
+
}
|
|
31
86
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
};
|
|
35
|
-
|
|
87
|
+
});
|
|
88
|
+
|
|
36
89
|
return {
|
|
90
|
+
/**
|
|
91
|
+
* Get the current computed value
|
|
92
|
+
*/
|
|
37
93
|
get value() {
|
|
38
|
-
if (
|
|
39
|
-
|
|
94
|
+
if (!isInitialized) {
|
|
95
|
+
throw new Error('Computed value accessed before initialization');
|
|
96
|
+
}
|
|
97
|
+
return cachedValue;
|
|
40
98
|
},
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Subscribe to changes in computed value
|
|
102
|
+
*
|
|
103
|
+
* @param {function} callback - Called when value changes
|
|
104
|
+
* @returns {function} Unsubscribe function
|
|
105
|
+
*/
|
|
106
|
+
subscribe(callback) {
|
|
107
|
+
if (typeof callback !== 'function') {
|
|
108
|
+
throw new Error('subscribe() requires a function');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
subscribers.push(callback);
|
|
112
|
+
|
|
113
|
+
// Call immediately with current value
|
|
114
|
+
if (isInitialized) {
|
|
115
|
+
callback(cachedValue);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Return unsubscribe function
|
|
119
|
+
return () => {
|
|
120
|
+
const index = subscribers.indexOf(callback);
|
|
121
|
+
if (index > -1) {
|
|
122
|
+
subscribers.splice(index, 1);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
51
125
|
},
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Clean up computed value and stop tracking
|
|
129
|
+
*/
|
|
130
|
+
dispose() {
|
|
131
|
+
cleanupEffect();
|
|
132
|
+
subscribers.length = 0;
|
|
133
|
+
isInitialized = false;
|
|
134
|
+
}
|
|
52
135
|
};
|
|
53
136
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lume.js Addons TypeScript Definitions
|
|
3
|
+
*
|
|
4
|
+
* Optional utilities for advanced reactive patterns.
|
|
5
|
+
* Import from "lume-js/addons" for tree-shaking.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Unsubscribe, Subscriber, ReactiveState } from '../index.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Computed value container returned by computed().
|
|
12
|
+
* T is the computed result type; when an error occurs the value becomes undefined.
|
|
13
|
+
*/
|
|
14
|
+
export interface Computed<T> {
|
|
15
|
+
/** Current computed value (undefined if computation threw). */
|
|
16
|
+
readonly value: T | undefined;
|
|
17
|
+
/** Subscribe to changes; immediate invocation with current value (may be undefined). */
|
|
18
|
+
subscribe(callback: Subscriber<T | undefined>): Unsubscribe;
|
|
19
|
+
/** Dispose computed value and stop tracking dependencies. */
|
|
20
|
+
dispose(): void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Create a computed value that automatically re-evaluates when accessed reactive state keys change.
|
|
25
|
+
* Uses effect() internally for dependency tracking.
|
|
26
|
+
*
|
|
27
|
+
* @param fn - Pure function that derives a value from reactive state.
|
|
28
|
+
* @returns Computed value container with .value, .subscribe(), and .dispose()
|
|
29
|
+
* @throws {Error} If fn is not a function
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* ```typescript
|
|
33
|
+
* import { state } from 'lume-js';
|
|
34
|
+
* import { computed } from 'lume-js/addons';
|
|
35
|
+
*
|
|
36
|
+
* const store = state({ count: 5 });
|
|
37
|
+
* const doubled = computed(() => store.count * 2);
|
|
38
|
+
*
|
|
39
|
+
* console.log(doubled.value); // 10
|
|
40
|
+
*
|
|
41
|
+
* store.count = 10;
|
|
42
|
+
* // After microtask:
|
|
43
|
+
* console.log(doubled.value); // 20 (auto-updated)
|
|
44
|
+
*
|
|
45
|
+
* // Subscribe to changes
|
|
46
|
+
* const unsub = doubled.subscribe(value => {
|
|
47
|
+
* console.log('Doubled:', value);
|
|
48
|
+
* });
|
|
49
|
+
*
|
|
50
|
+
* // Cleanup
|
|
51
|
+
* doubled.dispose();
|
|
52
|
+
* unsub();
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
export function computed<T>(fn: () => T): Computed<T>;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Watch a single key on a reactive state object; convenience wrapper around $subscribe.
|
|
59
|
+
*
|
|
60
|
+
* @param store - Reactive state object created with state().
|
|
61
|
+
* @param key - Property key to observe.
|
|
62
|
+
* @param callback - Invoked immediately and on subsequent changes.
|
|
63
|
+
* @returns Unsubscribe function for cleanup
|
|
64
|
+
* @throws {Error} If store is not a reactive state object
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* ```typescript
|
|
68
|
+
* import { state } from 'lume-js';
|
|
69
|
+
* import { watch } from 'lume-js/addons';
|
|
70
|
+
*
|
|
71
|
+
* const store = state({ count: 0 });
|
|
72
|
+
*
|
|
73
|
+
* const unwatch = watch(store, 'count', (value) => {
|
|
74
|
+
* console.log('Count is now:', value);
|
|
75
|
+
* });
|
|
76
|
+
*
|
|
77
|
+
* // Cleanup
|
|
78
|
+
* unwatch();
|
|
79
|
+
* ```
|
|
80
|
+
*/
|
|
81
|
+
export function watch<T extends object, K extends keyof T>(
|
|
82
|
+
store: ReactiveState<T>,
|
|
83
|
+
key: K,
|
|
84
|
+
callback: Subscriber<T[K]>
|
|
85
|
+
): Unsubscribe;
|
package/src/core/bindDom.js
CHANGED
|
@@ -4,10 +4,19 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Binds reactive state to DOM elements using [data-bind].
|
|
6
6
|
* Supports two-way binding for INPUT/TEXTAREA/SELECT.
|
|
7
|
+
*
|
|
8
|
+
* Automatically waits for DOMContentLoaded if the document is still loading,
|
|
9
|
+
* ensuring safe binding regardless of when the function is called.
|
|
7
10
|
*
|
|
8
11
|
* Usage:
|
|
9
12
|
* import { bindDom } from "lume-js";
|
|
13
|
+
*
|
|
14
|
+
* // Default: Auto-waits for DOM (safe anywhere)
|
|
10
15
|
* const cleanup = bindDom(document.body, store);
|
|
16
|
+
*
|
|
17
|
+
* // Advanced: Force immediate binding (no auto-wait)
|
|
18
|
+
* const cleanup = bindDom(myElement, store, { immediate: true });
|
|
19
|
+
*
|
|
11
20
|
* // Later: cleanup();
|
|
12
21
|
*
|
|
13
22
|
* HTML:
|
|
@@ -23,9 +32,11 @@ import { resolvePath } from "./utils.js";
|
|
|
23
32
|
*
|
|
24
33
|
* @param {HTMLElement} root - Root element to scan for [data-bind]
|
|
25
34
|
* @param {object} store - Reactive state object
|
|
35
|
+
* @param {object} [options] - Optional configuration
|
|
36
|
+
* @param {boolean} [options.immediate=false] - Skip auto-wait, bind immediately
|
|
26
37
|
* @returns {function} Cleanup function to remove all bindings
|
|
27
38
|
*/
|
|
28
|
-
export function bindDom(root, store) {
|
|
39
|
+
export function bindDom(root, store, options = {}) {
|
|
29
40
|
if (!(root instanceof HTMLElement)) {
|
|
30
41
|
throw new Error('bindDom() requires a valid HTMLElement as root');
|
|
31
42
|
}
|
|
@@ -34,52 +45,79 @@ export function bindDom(root, store) {
|
|
|
34
45
|
throw new Error('bindDom() requires a reactive state object');
|
|
35
46
|
}
|
|
36
47
|
|
|
37
|
-
const
|
|
38
|
-
const unsubscribers = [];
|
|
48
|
+
const { immediate = false } = options;
|
|
39
49
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
console.warn('[Lume.js] Empty data-bind attribute found', el);
|
|
45
|
-
return;
|
|
46
|
-
}
|
|
50
|
+
// Core binding logic extracted to separate function
|
|
51
|
+
const performBinding = () => {
|
|
52
|
+
const nodes = root.querySelectorAll("[data-bind]");
|
|
53
|
+
const cleanups = [];
|
|
47
54
|
|
|
48
|
-
|
|
49
|
-
|
|
55
|
+
nodes.forEach(el => {
|
|
56
|
+
const bindPath = el.getAttribute("data-bind");
|
|
57
|
+
|
|
58
|
+
if (!bindPath) {
|
|
59
|
+
console.warn('[Lume.js] Empty data-bind attribute found', el);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
50
62
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
target = resolvePath(store, pathArr);
|
|
54
|
-
} catch (err) {
|
|
55
|
-
console.warn(`[Lume.js] Invalid binding path "${bindPath}":`, err.message);
|
|
56
|
-
return;
|
|
57
|
-
}
|
|
63
|
+
const pathArr = bindPath.split(".");
|
|
64
|
+
const lastKey = pathArr.pop();
|
|
58
65
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
updateElement(el, val);
|
|
67
|
-
});
|
|
66
|
+
let target;
|
|
67
|
+
try {
|
|
68
|
+
target = resolvePath(store, pathArr);
|
|
69
|
+
} catch (err) {
|
|
70
|
+
console.warn(`[Lume.js] Invalid binding path "${bindPath}":`, err.message);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
68
73
|
|
|
69
|
-
|
|
74
|
+
if (!target || typeof target.$subscribe !== 'function') {
|
|
75
|
+
console.warn(`[Lume.js] Target for "${bindPath}" is not a reactive state object`);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
70
78
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
target[lastKey] = getInputValue(e.target);
|
|
79
|
+
// Subscribe to changes - receives already-batched notifications
|
|
80
|
+
const unsubscribe = target.$subscribe(lastKey, val => {
|
|
81
|
+
updateElement(el, val);
|
|
75
82
|
});
|
|
76
|
-
|
|
77
|
-
|
|
83
|
+
cleanups.push(unsubscribe);
|
|
84
|
+
|
|
85
|
+
// Two-way binding for form inputs
|
|
86
|
+
if (isFormInput(el)) {
|
|
87
|
+
const handler = e => {
|
|
88
|
+
target[lastKey] = getInputValue(e.target);
|
|
89
|
+
};
|
|
90
|
+
el.addEventListener("input", handler);
|
|
91
|
+
cleanups.push(() => el.removeEventListener("input", handler));
|
|
92
|
+
}
|
|
93
|
+
});
|
|
78
94
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
95
|
+
return () => {
|
|
96
|
+
cleanups.forEach(cleanup => cleanup());
|
|
97
|
+
};
|
|
82
98
|
};
|
|
99
|
+
|
|
100
|
+
// Auto-wait for DOM if needed (unless immediate flag is set)
|
|
101
|
+
if (!immediate && document.readyState === 'loading') {
|
|
102
|
+
let cleanup = null;
|
|
103
|
+
const onReady = () => {
|
|
104
|
+
cleanup = performBinding();
|
|
105
|
+
};
|
|
106
|
+
document.addEventListener('DOMContentLoaded', onReady, { once: true });
|
|
107
|
+
|
|
108
|
+
// Return cleanup function that handles both cases
|
|
109
|
+
return () => {
|
|
110
|
+
if (cleanup) {
|
|
111
|
+
cleanup();
|
|
112
|
+
} else {
|
|
113
|
+
// If cleanup hasn't been created yet, remove the event listener
|
|
114
|
+
document.removeEventListener('DOMContentLoaded', onReady);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Immediate binding (DOM already ready or immediate flag set)
|
|
120
|
+
return performBinding();
|
|
83
121
|
}
|
|
84
122
|
|
|
85
123
|
/**
|
|
@@ -89,8 +127,13 @@ export function bindDom(root, store) {
|
|
|
89
127
|
function updateElement(el, val) {
|
|
90
128
|
if (el.tagName === "INPUT") {
|
|
91
129
|
if (el.type === "checkbox") {
|
|
130
|
+
// Single checkbox: bind to boolean value
|
|
131
|
+
// For multiple checkboxes, use nested state objects:
|
|
132
|
+
// <input data-bind="tags.javascript"> → state({ tags: state({ javascript: true }) })
|
|
92
133
|
el.checked = Boolean(val);
|
|
93
134
|
} else if (el.type === "radio") {
|
|
135
|
+
// Radio: checked when el.value matches state value
|
|
136
|
+
// String() handles null/undefined gracefully (no radio selected)
|
|
94
137
|
el.checked = el.value === String(val);
|
|
95
138
|
} else {
|
|
96
139
|
el.value = val ?? '';
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lume-JS Effect
|
|
3
|
+
*
|
|
4
|
+
* Automatic dependency tracking for reactive effects.
|
|
5
|
+
* Tracks which state properties are accessed during execution
|
|
6
|
+
* and automatically re-runs when those properties change.
|
|
7
|
+
*
|
|
8
|
+
* Part of core because it's fundamental to modern reactivity.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* import { effect } from "lume-js";
|
|
12
|
+
*
|
|
13
|
+
* effect(() => {
|
|
14
|
+
* console.log('Count is:', store.count);
|
|
15
|
+
* // Automatically re-runs when store.count changes
|
|
16
|
+
* });
|
|
17
|
+
*
|
|
18
|
+
* Features:
|
|
19
|
+
* - Automatic dependency collection
|
|
20
|
+
* - Dynamic dependencies (tracks what you actually access)
|
|
21
|
+
* - Returns cleanup function
|
|
22
|
+
* - Plays nicely with per-state batching (no global scheduler)
|
|
23
|
+
*
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Creates an effect that automatically tracks dependencies
|
|
28
|
+
*
|
|
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
|
+
* @param {function} fn - Function to run reactively
|
|
34
|
+
* @returns {function} Cleanup function to stop the effect
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* const store = state({ count: 0, name: 'Alice' });
|
|
38
|
+
*
|
|
39
|
+
* const cleanup = effect(() => {
|
|
40
|
+
* // Only tracks 'count' (name not accessed)
|
|
41
|
+
* document.title = `Count: ${store.count}`;
|
|
42
|
+
* });
|
|
43
|
+
*
|
|
44
|
+
* store.count = 5; // Effect re-runs
|
|
45
|
+
* store.name = 'Bob'; // Effect does NOT re-run
|
|
46
|
+
*
|
|
47
|
+
* cleanup(); // Stop tracking
|
|
48
|
+
*/
|
|
49
|
+
export function effect(fn) {
|
|
50
|
+
if (typeof fn !== 'function') {
|
|
51
|
+
throw new Error('effect() requires a function');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const cleanups = [];
|
|
55
|
+
let isRunning = false; // Prevent infinite recursion
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Execute the effect function and collect dependencies
|
|
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.
|
|
63
|
+
*/
|
|
64
|
+
const execute = () => {
|
|
65
|
+
if (isRunning) return; // Prevent re-entry
|
|
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;
|
|
81
|
+
isRunning = true;
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
// Run the effect function (this triggers state getters)
|
|
85
|
+
fn();
|
|
86
|
+
} catch (error) {
|
|
87
|
+
console.error('[Lume.js effect] Error in effect:', error);
|
|
88
|
+
throw error;
|
|
89
|
+
} finally {
|
|
90
|
+
// Always clean up, even if error
|
|
91
|
+
globalThis.__LUME_CURRENT_EFFECT__ = undefined;
|
|
92
|
+
isRunning = false;
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// Run immediately to collect initial dependencies
|
|
97
|
+
execute();
|
|
98
|
+
|
|
99
|
+
// Return cleanup function
|
|
100
|
+
return () => {
|
|
101
|
+
cleanups.forEach(cleanup => cleanup());
|
|
102
|
+
cleanups.length = 0;
|
|
103
|
+
};
|
|
104
|
+
}
|
package/src/core/state.js
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
|
-
// src/core/state.js
|
|
2
1
|
/**
|
|
3
2
|
* Lume-JS Reactive State Core
|
|
4
3
|
*
|
|
5
4
|
* Provides minimal reactive state with standard JavaScript.
|
|
5
|
+
* Features automatic microtask batching for performance.
|
|
6
|
+
* Supports automatic dependency tracking for effects.
|
|
6
7
|
*
|
|
7
8
|
* Features:
|
|
8
9
|
* - Lightweight and Go-style
|
|
9
10
|
* - Explicit nested states
|
|
10
11
|
* - $subscribe for listening to key changes
|
|
11
12
|
* - Cleanup with unsubscribe
|
|
13
|
+
* - Per-state microtask batching for writes
|
|
14
|
+
* - Effect dependency tracking support (deduped per state flush)
|
|
12
15
|
*
|
|
13
16
|
* Usage:
|
|
14
17
|
* import { state } from "lume-js";
|
|
@@ -17,12 +20,18 @@
|
|
|
17
20
|
* unsub(); // cleanup
|
|
18
21
|
*/
|
|
19
22
|
|
|
23
|
+
// Per-state batching – each state object maintains its own microtask flush.
|
|
24
|
+
// This keeps effects simple and aligned with Lume's minimal philosophy.
|
|
25
|
+
|
|
20
26
|
/**
|
|
21
27
|
* Creates a reactive state object.
|
|
22
28
|
*
|
|
23
29
|
* @param {Object} obj - Initial state object
|
|
24
30
|
* @returns {Proxy} Reactive proxy with $subscribe method
|
|
25
31
|
*/
|
|
32
|
+
// Internal symbol used to mark reactive proxies (non-enumerable via Proxy trap)
|
|
33
|
+
const REACTIVE_MARKER = Symbol('__LUME_REACTIVE__');
|
|
34
|
+
|
|
26
35
|
export function state(obj) {
|
|
27
36
|
// Validate input
|
|
28
37
|
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
|
|
@@ -30,26 +39,101 @@ export function state(obj) {
|
|
|
30
39
|
}
|
|
31
40
|
|
|
32
41
|
const listeners = {};
|
|
42
|
+
const pendingNotifications = new Map(); // Per-state pending changes
|
|
43
|
+
const pendingEffects = new Set(); // Dedupe effects per state
|
|
44
|
+
let flushScheduled = false;
|
|
33
45
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
46
|
+
/**
|
|
47
|
+
* Schedule a single microtask flush for this state object.
|
|
48
|
+
*
|
|
49
|
+
* Flush order per state:
|
|
50
|
+
* 1) Notify subscribers for changed keys (key → subscribers)
|
|
51
|
+
* 2) Run each queued effect exactly once (Set-based dedupe)
|
|
52
|
+
*
|
|
53
|
+
* Notes:
|
|
54
|
+
* - Batching is per state; effects that depend on multiple states
|
|
55
|
+
* may run once per state that changed (by design).
|
|
56
|
+
*/
|
|
57
|
+
function scheduleFlush() {
|
|
58
|
+
if (flushScheduled) return;
|
|
59
|
+
|
|
60
|
+
flushScheduled = true;
|
|
61
|
+
queueMicrotask(() => {
|
|
62
|
+
flushScheduled = false;
|
|
63
|
+
|
|
64
|
+
// Notify all subscribers of changed keys
|
|
65
|
+
// Snapshot listeners array to handle unsubscribes during iteration
|
|
66
|
+
for (const [key, value] of pendingNotifications) {
|
|
67
|
+
if (listeners[key]) {
|
|
68
|
+
const subscribersSnapshot = Array.from(listeners[key]);
|
|
69
|
+
subscribersSnapshot.forEach(fn => fn(value));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
pendingNotifications.clear();
|
|
74
|
+
|
|
75
|
+
// Run each effect exactly once (Set deduplicates)
|
|
76
|
+
const effects = Array.from(pendingEffects);
|
|
77
|
+
pendingEffects.clear();
|
|
78
|
+
effects.forEach(effect => effect());
|
|
79
|
+
});
|
|
39
80
|
}
|
|
40
81
|
|
|
41
82
|
const proxy = new Proxy(obj, {
|
|
42
83
|
get(target, key) {
|
|
84
|
+
// Reactive marker check (avoid tracking for internal symbol)
|
|
85
|
+
if (key === REACTIVE_MARKER) return true;
|
|
86
|
+
// Skip effect tracking for internal meta methods (e.g. $subscribe)
|
|
87
|
+
if (typeof key === 'string' && key.startsWith('$')) {
|
|
88
|
+
return target[key];
|
|
89
|
+
}
|
|
90
|
+
// Support effect tracking
|
|
91
|
+
// Check if we're inside an effect context
|
|
92
|
+
if (typeof globalThis.__LUME_CURRENT_EFFECT__ !== 'undefined') {
|
|
93
|
+
const currentEffect = globalThis.__LUME_CURRENT_EFFECT__;
|
|
94
|
+
|
|
95
|
+
if (currentEffect && !currentEffect.tracking[key]) {
|
|
96
|
+
// Mark as tracked
|
|
97
|
+
currentEffect.tracking[key] = true;
|
|
98
|
+
|
|
99
|
+
// Subscribe to changes for this key (skip initial call for effects)
|
|
100
|
+
const unsubscribe = (() => {
|
|
101
|
+
if (!listeners[key]) listeners[key] = [];
|
|
102
|
+
|
|
103
|
+
const effectFn = () => {
|
|
104
|
+
// Queue effect in this state's pending set
|
|
105
|
+
// Set deduplicates - effect runs once even if multiple keys change
|
|
106
|
+
pendingEffects.add(currentEffect.execute);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
listeners[key].push(effectFn);
|
|
110
|
+
|
|
111
|
+
// Return unsubscribe function (no initial call for effects)
|
|
112
|
+
return () => {
|
|
113
|
+
if (listeners[key]) {
|
|
114
|
+
listeners[key] = listeners[key].filter(subscriber => subscriber !== effectFn);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
})();
|
|
118
|
+
|
|
119
|
+
// Store cleanup function
|
|
120
|
+
currentEffect.cleanups.push(unsubscribe);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
43
124
|
return target[key];
|
|
44
125
|
},
|
|
126
|
+
|
|
45
127
|
set(target, key, value) {
|
|
46
128
|
const oldValue = target[key];
|
|
129
|
+
if (oldValue === value) return true;
|
|
130
|
+
|
|
47
131
|
target[key] = value;
|
|
48
132
|
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
133
|
+
// Batch notifications at the state level (per-state, not global)
|
|
134
|
+
pendingNotifications.set(key, value);
|
|
135
|
+
scheduleFlush();
|
|
136
|
+
|
|
53
137
|
return true;
|
|
54
138
|
}
|
|
55
139
|
});
|
|
@@ -71,7 +155,7 @@ export function state(obj) {
|
|
|
71
155
|
if (!listeners[key]) listeners[key] = [];
|
|
72
156
|
listeners[key].push(fn);
|
|
73
157
|
|
|
74
|
-
// Call immediately with current value
|
|
158
|
+
// Call immediately with current value (NOT batched)
|
|
75
159
|
fn(proxy[key]);
|
|
76
160
|
|
|
77
161
|
// Return unsubscribe function
|
|
@@ -83,4 +167,14 @@ export function state(obj) {
|
|
|
83
167
|
};
|
|
84
168
|
|
|
85
169
|
return proxy;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Determine if an object is a Lume reactive proxy.
|
|
174
|
+
* Defensive: ensures object and marker presence.
|
|
175
|
+
* @param {any} obj
|
|
176
|
+
* @returns {boolean}
|
|
177
|
+
*/
|
|
178
|
+
export function isReactive(obj) {
|
|
179
|
+
return !!(obj && typeof obj === 'object' && obj[REACTIVE_MARKER]);
|
|
86
180
|
}
|