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/package.json CHANGED
@@ -1,16 +1,59 @@
1
1
  {
2
2
  "name": "lume-js",
3
- "version": "0.2.1",
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 yet, zero-runtime already!'"
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
  }
@@ -1,38 +1,136 @@
1
1
  /**
2
- * computed - creates a derived value based on state
3
- * @param {Function} fn - function that computes value from state
4
- * @returns {Object} - { get: () => value, recompute: () => void, subscribe: (cb) => unsubscribe }
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
- let value;
8
- let dirty = true;
9
- const subscribers = new Set();
55
+ if (typeof fn !== 'function') {
56
+ throw new Error('computed() requires a function');
57
+ }
10
58
 
11
- const recalc = () => {
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
- value = fn();
14
- } catch (err) {
15
- console.error("[computed] Error computing value:", err);
16
- value = undefined;
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
- dirty = false;
19
- subscribers.forEach(cb => cb(value));
20
- };
21
-
87
+ });
88
+
22
89
  return {
23
- get: () => {
24
- if (dirty) recalc();
25
- return value;
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
- recompute: () => {
28
- dirty = true;
29
- recalc();
30
- },
31
- subscribe: cb => {
32
- subscribers.add(cb);
33
- // Immediately notify subscriber with current value
34
- if (!dirty) cb(value);
35
- return () => subscribers.delete(cb); // unsubscribe function
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
+ }
@@ -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.subscribe) {
9
+ if (!store.$subscribe) {
9
10
  throw new Error("store must be created with state()");
10
11
  }
11
- store.subscribe(key, callback);
12
- }
12
+ return store.$subscribe(key, callback);
13
+ }
@@ -1,50 +1,128 @@
1
+ // src/core/bindDom.js
1
2
  /**
2
- * Lume-JS Zero-runtime DOM binding
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
- * Zero-runtime DOM binding for a reactive store
22
+ * DOM binding for reactive state
20
23
  *
21
- * @param {HTMLElement} root - root element to scan for [data-bind]
22
- * @param {object} store - reactive state object
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 pathArr = el.getAttribute("data-bind").split(".");
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); // must be wrapped with state() if nested
53
+ target = resolvePath(store, pathArr);
34
54
  } catch (err) {
35
- console.warn(`Skipping binding for ${el}: ${err.message}`);
55
+ console.warn(`[Lume.js] Invalid binding path "${bindPath}":`, err.message);
36
56
  return;
37
57
  }
38
58
 
39
- // Subscribe once
40
- target.subscribe(lastKey, val => {
41
- if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") el.value = val;
42
- else el.textContent = val;
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
- // 2-way binding for inputs
46
- if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") {
47
- el.addEventListener("input", e => target[lastKey] = e.target.value);
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, zero-runtime reactive state.
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.subscribe("count", val => console.log(val));
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
- // Notify subscribers of a key
28
- function notify(key, val) {
29
- if (listeners[key]) listeners[key].forEach(fn => fn(val));
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
- notify(key, value);
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.subscribe = (key, fn) => {
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
- fn(proxy[key]); // initialize
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
+ }