lume-js 0.2.1 → 0.4.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 +623 -24
- package/package.json +47 -4
- package/src/addons/computed.js +126 -28
- package/src/addons/watch.js +4 -3
- package/src/core/bindDom.js +94 -16
- package/src/core/effect.js +104 -0
- package/src/core/state.js +115 -13
- package/src/core/utils.js +33 -6
- package/src/index.d.ts +115 -0
- package/src/index.js +3 -1
package/package.json
CHANGED
|
@@ -1,16 +1,59 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lume-js",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Minimal reactive state management using only standard JavaScript and HTML - no custom syntax, no build step required",
|
|
4
5
|
"main": "src/index.js",
|
|
6
|
+
"types": "src/index.d.ts",
|
|
5
7
|
"type": "module",
|
|
6
8
|
"scripts": {
|
|
7
9
|
"dev": "vite",
|
|
8
|
-
"build": "echo 'No build step
|
|
10
|
+
"build": "echo 'No build step needed - zero-runtime library!'",
|
|
11
|
+
"size": "node scripts/check-size.js",
|
|
12
|
+
"test": "vitest run",
|
|
13
|
+
"test:watch": "vitest",
|
|
14
|
+
"coverage": "vitest run --coverage"
|
|
9
15
|
},
|
|
10
16
|
"files": [
|
|
11
|
-
"src"
|
|
17
|
+
"src",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE"
|
|
12
20
|
],
|
|
21
|
+
"keywords": [
|
|
22
|
+
"reactive",
|
|
23
|
+
"state",
|
|
24
|
+
"dom",
|
|
25
|
+
"binding",
|
|
26
|
+
"standards",
|
|
27
|
+
"minimal",
|
|
28
|
+
"no-build",
|
|
29
|
+
"vanilla-js",
|
|
30
|
+
"data-bind",
|
|
31
|
+
"proxy",
|
|
32
|
+
"knockout",
|
|
33
|
+
"html-first",
|
|
34
|
+
"framework-agnostic",
|
|
35
|
+
"minimal-runtime",
|
|
36
|
+
"no-vdom",
|
|
37
|
+
"web-standards",
|
|
38
|
+
"lightweight"
|
|
39
|
+
],
|
|
40
|
+
"author": "Sathvik C",
|
|
41
|
+
"license": "MIT",
|
|
42
|
+
"repository": {
|
|
43
|
+
"type": "git",
|
|
44
|
+
"url": "https://github.com/sathvikc/lume-js.git"
|
|
45
|
+
},
|
|
46
|
+
"bugs": {
|
|
47
|
+
"url": "https://github.com/sathvikc/lume-js/issues"
|
|
48
|
+
},
|
|
49
|
+
"homepage": "https://github.com/sathvikc/lume-js#readme",
|
|
13
50
|
"devDependencies": {
|
|
14
|
-
"vite": "^7.1.9"
|
|
51
|
+
"vite": "^7.1.9",
|
|
52
|
+
"vitest": "^2.1.4",
|
|
53
|
+
"@vitest/coverage-v8": "^2.1.4",
|
|
54
|
+
"jsdom": "^25.0.1"
|
|
55
|
+
},
|
|
56
|
+
"engines": {
|
|
57
|
+
"node": ">=20.19.0"
|
|
15
58
|
}
|
|
16
59
|
}
|
package/src/addons/computed.js
CHANGED
|
@@ -1,38 +1,136 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Lume-JS Computed Addon
|
|
3
|
+
*
|
|
4
|
+
* Creates computed values that automatically update when dependencies change.
|
|
5
|
+
* Uses core effect() for automatic dependency tracking.
|
|
6
|
+
*
|
|
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
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* const store = state({ count: 5 });
|
|
36
|
+
*
|
|
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)
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* // Subscribe to changes
|
|
46
|
+
* const unsub = doubled.subscribe(value => {
|
|
47
|
+
* console.log('Doubled changed to:', value);
|
|
48
|
+
* });
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* // Cleanup
|
|
52
|
+
* doubled.dispose();
|
|
5
53
|
*/
|
|
6
54
|
export function computed(fn) {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
55
|
+
if (typeof fn !== 'function') {
|
|
56
|
+
throw new Error('computed() requires a function');
|
|
57
|
+
}
|
|
10
58
|
|
|
11
|
-
|
|
59
|
+
let cachedValue;
|
|
60
|
+
let isInitialized = false;
|
|
61
|
+
const subscribers = [];
|
|
62
|
+
|
|
63
|
+
// Use effect to automatically track dependencies
|
|
64
|
+
const cleanupEffect = effect(() => {
|
|
12
65
|
try {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
+
}
|
|
17
86
|
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
};
|
|
21
|
-
|
|
87
|
+
});
|
|
88
|
+
|
|
22
89
|
return {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
90
|
+
/**
|
|
91
|
+
* Get the current computed value
|
|
92
|
+
*/
|
|
93
|
+
get value() {
|
|
94
|
+
if (!isInitialized) {
|
|
95
|
+
throw new Error('Computed value accessed before initialization');
|
|
96
|
+
}
|
|
97
|
+
return cachedValue;
|
|
26
98
|
},
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
+
};
|
|
36
125
|
},
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Clean up computed value and stop tracking
|
|
129
|
+
*/
|
|
130
|
+
dispose() {
|
|
131
|
+
cleanupEffect();
|
|
132
|
+
subscribers.length = 0;
|
|
133
|
+
isInitialized = false;
|
|
134
|
+
}
|
|
37
135
|
};
|
|
38
|
-
}
|
|
136
|
+
}
|
package/src/addons/watch.js
CHANGED
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
* @param {Object} store - reactive store created with state()
|
|
4
4
|
* @param {string} key - key in store to watch
|
|
5
5
|
* @param {Function} callback - called with new value
|
|
6
|
+
* @returns {Function} unsubscribe function
|
|
6
7
|
*/
|
|
7
8
|
export function watch(store, key, callback) {
|
|
8
|
-
if (!store
|
|
9
|
+
if (!store.$subscribe) {
|
|
9
10
|
throw new Error("store must be created with state()");
|
|
10
11
|
}
|
|
11
|
-
store
|
|
12
|
-
}
|
|
12
|
+
return store.$subscribe(key, callback);
|
|
13
|
+
}
|
package/src/core/bindDom.js
CHANGED
|
@@ -1,50 +1,128 @@
|
|
|
1
|
+
// src/core/bindDom.js
|
|
1
2
|
/**
|
|
2
|
-
* Lume-JS
|
|
3
|
+
* Lume-JS DOM Binding
|
|
3
4
|
*
|
|
4
5
|
* Binds reactive state to DOM elements using [data-bind].
|
|
5
|
-
* Supports two-way binding for INPUT/TEXTAREA.
|
|
6
|
+
* Supports two-way binding for INPUT/TEXTAREA/SELECT.
|
|
6
7
|
*
|
|
7
8
|
* Usage:
|
|
8
9
|
* import { bindDom } from "lume-js";
|
|
9
|
-
* bindDom(document.body, store);
|
|
10
|
+
* const cleanup = bindDom(document.body, store);
|
|
11
|
+
* // Later: cleanup();
|
|
10
12
|
*
|
|
11
13
|
* HTML:
|
|
12
14
|
* <span data-bind="count"></span>
|
|
13
15
|
* <input data-bind="user.name">
|
|
16
|
+
* <select data-bind="theme"></select>
|
|
14
17
|
*/
|
|
15
18
|
|
|
16
19
|
import { resolvePath } from "./utils.js";
|
|
17
20
|
|
|
18
21
|
/**
|
|
19
|
-
*
|
|
22
|
+
* DOM binding for reactive state
|
|
20
23
|
*
|
|
21
|
-
* @param {HTMLElement} root -
|
|
22
|
-
* @param {object} store -
|
|
24
|
+
* @param {HTMLElement} root - Root element to scan for [data-bind]
|
|
25
|
+
* @param {object} store - Reactive state object
|
|
26
|
+
* @returns {function} Cleanup function to remove all bindings
|
|
23
27
|
*/
|
|
24
28
|
export function bindDom(root, store) {
|
|
29
|
+
if (!(root instanceof HTMLElement)) {
|
|
30
|
+
throw new Error('bindDom() requires a valid HTMLElement as root');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!store || typeof store !== 'object') {
|
|
34
|
+
throw new Error('bindDom() requires a reactive state object');
|
|
35
|
+
}
|
|
36
|
+
|
|
25
37
|
const nodes = root.querySelectorAll("[data-bind]");
|
|
38
|
+
const cleanups = [];
|
|
26
39
|
|
|
27
40
|
nodes.forEach(el => {
|
|
28
|
-
const
|
|
41
|
+
const bindPath = el.getAttribute("data-bind");
|
|
42
|
+
|
|
43
|
+
if (!bindPath) {
|
|
44
|
+
console.warn('[Lume.js] Empty data-bind attribute found', el);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const pathArr = bindPath.split(".");
|
|
29
49
|
const lastKey = pathArr.pop();
|
|
30
50
|
|
|
31
51
|
let target;
|
|
32
52
|
try {
|
|
33
|
-
target = resolvePath(store, pathArr);
|
|
53
|
+
target = resolvePath(store, pathArr);
|
|
34
54
|
} catch (err) {
|
|
35
|
-
console.warn(`
|
|
55
|
+
console.warn(`[Lume.js] Invalid binding path "${bindPath}":`, err.message);
|
|
36
56
|
return;
|
|
37
57
|
}
|
|
38
58
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
59
|
+
if (!target || typeof target.$subscribe !== 'function') {
|
|
60
|
+
console.warn(`[Lume.js] Target for "${bindPath}" is not a reactive state object`);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Subscribe to changes - receives already-batched notifications
|
|
65
|
+
const unsubscribe = target.$subscribe(lastKey, val => {
|
|
66
|
+
updateElement(el, val);
|
|
43
67
|
});
|
|
68
|
+
cleanups.push(unsubscribe);
|
|
44
69
|
|
|
45
|
-
//
|
|
46
|
-
if (el
|
|
47
|
-
|
|
70
|
+
// Two-way binding for form inputs
|
|
71
|
+
if (isFormInput(el)) {
|
|
72
|
+
const handler = e => {
|
|
73
|
+
target[lastKey] = getInputValue(e.target);
|
|
74
|
+
};
|
|
75
|
+
el.addEventListener("input", handler);
|
|
76
|
+
cleanups.push(() => el.removeEventListener("input", handler));
|
|
48
77
|
}
|
|
49
78
|
});
|
|
79
|
+
|
|
80
|
+
return () => {
|
|
81
|
+
cleanups.forEach(cleanup => cleanup());
|
|
82
|
+
};
|
|
50
83
|
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Update DOM element with new value
|
|
87
|
+
* @private
|
|
88
|
+
*/
|
|
89
|
+
function updateElement(el, val) {
|
|
90
|
+
if (el.tagName === "INPUT") {
|
|
91
|
+
if (el.type === "checkbox") {
|
|
92
|
+
el.checked = Boolean(val);
|
|
93
|
+
} else if (el.type === "radio") {
|
|
94
|
+
el.checked = el.value === String(val);
|
|
95
|
+
} else {
|
|
96
|
+
el.value = val ?? '';
|
|
97
|
+
}
|
|
98
|
+
} else if (el.tagName === "TEXTAREA") {
|
|
99
|
+
el.value = val ?? '';
|
|
100
|
+
} else if (el.tagName === "SELECT") {
|
|
101
|
+
el.value = val ?? '';
|
|
102
|
+
} else {
|
|
103
|
+
el.textContent = val ?? '';
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Get value from form input
|
|
109
|
+
* @private
|
|
110
|
+
*/
|
|
111
|
+
function getInputValue(el) {
|
|
112
|
+
if (el.type === "checkbox") {
|
|
113
|
+
return el.checked;
|
|
114
|
+
} else if (el.type === "number" || el.type === "range") {
|
|
115
|
+
return el.valueAsNumber;
|
|
116
|
+
}
|
|
117
|
+
return el.value;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Check if element is a form input
|
|
122
|
+
* @private
|
|
123
|
+
*/
|
|
124
|
+
function isFormInput(el) {
|
|
125
|
+
return el.tagName === "INPUT" ||
|
|
126
|
+
el.tagName === "TEXTAREA" ||
|
|
127
|
+
el.tagName === "SELECT";
|
|
128
|
+
}
|
|
@@ -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,41 +1,128 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Lume-JS Reactive State Core
|
|
3
3
|
*
|
|
4
|
-
* Provides minimal
|
|
4
|
+
* Provides minimal reactive state with standard JavaScript.
|
|
5
|
+
* Features automatic microtask batching for performance.
|
|
6
|
+
* Supports automatic dependency tracking for effects.
|
|
5
7
|
*
|
|
6
8
|
* Features:
|
|
7
9
|
* - Lightweight and Go-style
|
|
8
10
|
* - Explicit nested states
|
|
9
|
-
* - subscribe for listening to key changes
|
|
11
|
+
* - $subscribe for listening to key changes
|
|
12
|
+
* - Cleanup with unsubscribe
|
|
13
|
+
* - Per-state microtask batching for writes
|
|
14
|
+
* - Effect dependency tracking support (deduped per state flush)
|
|
10
15
|
*
|
|
11
16
|
* Usage:
|
|
12
17
|
* import { state } from "lume-js";
|
|
13
18
|
* const store = state({ count: 0 });
|
|
14
|
-
* store
|
|
19
|
+
* const unsub = store.$subscribe("count", val => console.log(val));
|
|
20
|
+
* unsub(); // cleanup
|
|
15
21
|
*/
|
|
16
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.
|
|
17
25
|
|
|
18
26
|
/**
|
|
19
27
|
* Creates a reactive state object.
|
|
20
28
|
*
|
|
21
29
|
* @param {Object} obj - Initial state object
|
|
22
|
-
* @returns {Proxy} Reactive proxy with subscribe method
|
|
30
|
+
* @returns {Proxy} Reactive proxy with $subscribe method
|
|
23
31
|
*/
|
|
24
32
|
export function state(obj) {
|
|
33
|
+
// Validate input
|
|
34
|
+
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
|
|
35
|
+
throw new Error('state() requires a plain object');
|
|
36
|
+
}
|
|
37
|
+
|
|
25
38
|
const listeners = {};
|
|
39
|
+
const pendingNotifications = new Map(); // Per-state pending changes
|
|
40
|
+
const pendingEffects = new Set(); // Dedupe effects per state
|
|
41
|
+
let flushScheduled = false;
|
|
26
42
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
43
|
+
/**
|
|
44
|
+
* Schedule a single microtask flush for this state object.
|
|
45
|
+
*
|
|
46
|
+
* Flush order per state:
|
|
47
|
+
* 1) Notify subscribers for changed keys (key → subscribers)
|
|
48
|
+
* 2) Run each queued effect exactly once (Set-based dedupe)
|
|
49
|
+
*
|
|
50
|
+
* Notes:
|
|
51
|
+
* - Batching is per state; effects that depend on multiple states
|
|
52
|
+
* may run once per state that changed (by design).
|
|
53
|
+
*/
|
|
54
|
+
function scheduleFlush() {
|
|
55
|
+
if (flushScheduled) return;
|
|
56
|
+
|
|
57
|
+
flushScheduled = true;
|
|
58
|
+
queueMicrotask(() => {
|
|
59
|
+
flushScheduled = false;
|
|
60
|
+
|
|
61
|
+
// Notify all subscribers of changed keys
|
|
62
|
+
for (const [key, value] of pendingNotifications) {
|
|
63
|
+
if (listeners[key]) {
|
|
64
|
+
listeners[key].forEach(fn => fn(value));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
pendingNotifications.clear();
|
|
69
|
+
|
|
70
|
+
// Run each effect exactly once (Set deduplicates)
|
|
71
|
+
const effects = Array.from(pendingEffects);
|
|
72
|
+
pendingEffects.clear();
|
|
73
|
+
effects.forEach(effect => effect());
|
|
74
|
+
});
|
|
30
75
|
}
|
|
31
76
|
|
|
32
77
|
const proxy = new Proxy(obj, {
|
|
33
78
|
get(target, key) {
|
|
79
|
+
// Support effect tracking
|
|
80
|
+
// Check if we're inside an effect context
|
|
81
|
+
if (typeof globalThis.__LUME_CURRENT_EFFECT__ !== 'undefined') {
|
|
82
|
+
const currentEffect = globalThis.__LUME_CURRENT_EFFECT__;
|
|
83
|
+
|
|
84
|
+
if (currentEffect && !currentEffect.tracking[key]) {
|
|
85
|
+
// Mark as tracked
|
|
86
|
+
currentEffect.tracking[key] = true;
|
|
87
|
+
|
|
88
|
+
// Subscribe to changes for this key (skip initial call for effects)
|
|
89
|
+
const unsubscribe = (() => {
|
|
90
|
+
if (!listeners[key]) listeners[key] = [];
|
|
91
|
+
|
|
92
|
+
const effectFn = () => {
|
|
93
|
+
// Queue effect in this state's pending set
|
|
94
|
+
// Set deduplicates - effect runs once even if multiple keys change
|
|
95
|
+
pendingEffects.add(currentEffect.execute);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
listeners[key].push(effectFn);
|
|
99
|
+
|
|
100
|
+
// Return unsubscribe function (no initial call for effects)
|
|
101
|
+
return () => {
|
|
102
|
+
if (listeners[key]) {
|
|
103
|
+
listeners[key] = listeners[key].filter(subscriber => subscriber !== effectFn);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
})();
|
|
107
|
+
|
|
108
|
+
// Store cleanup function
|
|
109
|
+
currentEffect.cleanups.push(unsubscribe);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
34
113
|
return target[key];
|
|
35
114
|
},
|
|
115
|
+
|
|
36
116
|
set(target, key, value) {
|
|
117
|
+
const oldValue = target[key];
|
|
118
|
+
if (oldValue === value) return true;
|
|
119
|
+
|
|
37
120
|
target[key] = value;
|
|
38
|
-
|
|
121
|
+
|
|
122
|
+
// Batch notifications at the state level (per-state, not global)
|
|
123
|
+
pendingNotifications.set(key, value);
|
|
124
|
+
scheduleFlush();
|
|
125
|
+
|
|
39
126
|
return true;
|
|
40
127
|
}
|
|
41
128
|
});
|
|
@@ -43,15 +130,30 @@ export function state(obj) {
|
|
|
43
130
|
/**
|
|
44
131
|
* Subscribe to changes for a specific key.
|
|
45
132
|
* Calls the callback immediately with the current value.
|
|
133
|
+
* Returns an unsubscribe function for cleanup.
|
|
46
134
|
*
|
|
47
|
-
* @param {string} key
|
|
48
|
-
* @param {function} fn
|
|
135
|
+
* @param {string} key - Property key to watch
|
|
136
|
+
* @param {function} fn - Callback function
|
|
137
|
+
* @returns {function} Unsubscribe function
|
|
49
138
|
*/
|
|
50
|
-
proxy
|
|
139
|
+
proxy.$subscribe = (key, fn) => {
|
|
140
|
+
if (typeof fn !== 'function') {
|
|
141
|
+
throw new Error('Subscriber must be a function');
|
|
142
|
+
}
|
|
143
|
+
|
|
51
144
|
if (!listeners[key]) listeners[key] = [];
|
|
52
145
|
listeners[key].push(fn);
|
|
53
|
-
|
|
146
|
+
|
|
147
|
+
// Call immediately with current value (NOT batched)
|
|
148
|
+
fn(proxy[key]);
|
|
149
|
+
|
|
150
|
+
// Return unsubscribe function
|
|
151
|
+
return () => {
|
|
152
|
+
if (listeners[key]) {
|
|
153
|
+
listeners[key] = listeners[key].filter(subscriber => subscriber !== fn);
|
|
154
|
+
}
|
|
155
|
+
};
|
|
54
156
|
};
|
|
55
157
|
|
|
56
158
|
return proxy;
|
|
57
|
-
}
|
|
159
|
+
}
|