lume-js 0.3.0 → 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 CHANGED
@@ -5,7 +5,7 @@
5
5
  Minimal reactive state management using only standard JavaScript and HTML - no custom syntax, no build step required, no framework lock-in.
6
6
 
7
7
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
8
- [![Version](https://img.shields.io/badge/version-0.2.1-green.svg)](package.json)
8
+ [![Version](https://img.shields.io/badge/version-0.4.0-green.svg)](package.json)
9
9
 
10
10
  ## Why Lume.js?
11
11
 
@@ -43,7 +43,10 @@ npm install lume-js
43
43
 
44
44
  ```html
45
45
  <script type="module">
46
- import { state, bindDom } from 'https://cdn.jsdelivr.net/npm/lume-js/src/index.js';
46
+ import { state, bindDom, effect } from 'https://cdn.jsdelivr.net/npm/lume-js/src/index.js';
47
+
48
+ // For addons:
49
+ import { computed, watch } from 'https://cdn.jsdelivr.net/npm/lume-js/src/addons/index.js';
47
50
  </script>
48
51
  ```
49
52
 
@@ -51,6 +54,16 @@ npm install lume-js
51
54
 
52
55
  ## Quick Start
53
56
 
57
+ ### Examples
58
+
59
+ Run the live examples (including the new Todo app) with Vite:
60
+
61
+ ```bash
62
+ npm run dev
63
+ ```
64
+
65
+ Then open the Examples index (Vite will auto-open): `http://localhost:5173/examples/`
66
+
54
67
  **HTML:**
55
68
  ```html
56
69
  <div>
@@ -64,7 +77,7 @@ npm install lume-js
64
77
 
65
78
  **JavaScript:**
66
79
  ```javascript
67
- import { state, bindDom } from 'lume-js';
80
+ import { state, bindDom, effect } from 'lume-js';
68
81
 
69
82
  // Create reactive state
70
83
  const store = state({
@@ -72,16 +85,24 @@ const store = state({
72
85
  name: 'World'
73
86
  });
74
87
 
75
- // Bind to DOM
88
+ // Bind to DOM (updates on state changes)
76
89
  const cleanup = bindDom(document.body, store);
77
90
 
91
+ // Auto-update document title when name changes
92
+ const effectCleanup = effect(() => {
93
+ document.title = `Hello, ${store.name}!`;
94
+ });
95
+
78
96
  // Update state with standard JavaScript
79
97
  document.getElementById('increment').addEventListener('click', () => {
80
98
  store.count++;
81
99
  });
82
100
 
83
101
  // Cleanup when done (important!)
84
- window.addEventListener('beforeunload', () => cleanup());
102
+ window.addEventListener('beforeunload', () => {
103
+ cleanup();
104
+ effectCleanup();
105
+ });
85
106
  ```
86
107
 
87
108
  That's it! No build step, no custom syntax, just HTML and JavaScript.
@@ -92,7 +113,7 @@ That's it! No build step, no custom syntax, just HTML and JavaScript.
92
113
 
93
114
  ### `state(object)`
94
115
 
95
- Creates a reactive state object using Proxy.
116
+ Creates a reactive state object using Proxy with automatic dependency tracking.
96
117
 
97
118
  ```javascript
98
119
  const store = state({
@@ -109,9 +130,39 @@ store.user.name = 'Bob';
109
130
  ```
110
131
 
111
132
  **Features:**
133
+ - ✅ Automatic dependency tracking for effects
134
+ - ✅ Per-state microtask batching for performance
112
135
  - ✅ Validates input (must be plain object)
113
136
  - ✅ Only triggers updates when value actually changes
114
137
  - ✅ Returns cleanup function from `$subscribe`
138
+ - ✅ Deduplicates effect runs per flush cycle
139
+
140
+ ### `effect(fn)`
141
+
142
+ Creates an effect that automatically tracks dependencies and re-runs when they change.
143
+
144
+ ```javascript
145
+ const store = state({ count: 0, name: 'Alice' });
146
+
147
+ const cleanup = effect(() => {
148
+ // Only tracks 'count' (name is not accessed)
149
+ console.log(`Count: ${store.count}`);
150
+ });
151
+
152
+ store.count = 5; // Effect re-runs
153
+ store.name = 'Bob'; // Effect does NOT re-run
154
+
155
+ cleanup(); // Stop the effect
156
+ ```
157
+
158
+ **Features:**
159
+ - ✅ Automatic dependency collection (tracks what you actually access)
160
+ - ✅ Dynamic dependencies (re-tracks on every execution)
161
+ - ✅ Returns cleanup function
162
+ - ✅ Prevents infinite recursion
163
+ - ✅ Integrates with per-state batching (no global scheduler)
164
+
165
+ **How it works:** Effects use `globalThis.__LUME_CURRENT_EFFECT__` to track which state properties are accessed during execution. When any tracked property changes, the effect is queued in that state's pending effects set and runs once in the next microtask.
115
166
 
116
167
  ### `bindDom(root, store)`
117
168
 
@@ -134,14 +185,15 @@ cleanup();
134
185
  - ✅ Radio buttons: `<input type="radio" data-bind="choice">`
135
186
  - ✅ Nested paths: `<span data-bind="user.name"></span>`
136
187
 
137
- **NEW in v0.3.0:**
138
- - Returns cleanup function
139
- - Better error messages with `[Lume.js]` prefix
140
- - Handles edge cases (empty bindings, invalid paths)
188
+ **Features:**
189
+ - Returns cleanup function
190
+ - Better error messages with `[Lume.js]` prefix
191
+ - Handles edge cases (empty bindings, invalid paths)
192
+ - ✅ Two-way binding for form inputs
141
193
 
142
194
  ### `$subscribe(key, callback)`
143
195
 
144
- Manually subscribe to state changes. Returns unsubscribe function.
196
+ Manually subscribe to state changes. Calls callback immediately with current value, then on every change.
145
197
 
146
198
  ```javascript
147
199
  const unsubscribe = store.$subscribe('count', (value) => {
@@ -157,10 +209,71 @@ const unsubscribe = store.$subscribe('count', (value) => {
157
209
  unsubscribe();
158
210
  ```
159
211
 
160
- **NEW in v0.3.0:**
161
- - Returns unsubscribe function (was missing in v0.2.x)
162
- - Validates callback is a function
163
- - Only notifies on actual value changes
212
+ **Features:**
213
+ - Returns unsubscribe function
214
+ - Validates callback is a function
215
+ - Calls immediately with current value (not batched)
216
+ - ✅ Only notifies on actual value changes (via batching)
217
+
218
+ ---
219
+
220
+ ## Addons
221
+
222
+ Lume.js provides optional addons for advanced reactivity patterns. Import from `lume-js/addons`.
223
+
224
+ ### `computed(fn)`
225
+
226
+ Creates a computed value that automatically updates when its dependencies change.
227
+
228
+ ```javascript
229
+ import { state, effect } from 'lume-js';
230
+ import { computed } from 'lume-js/addons';
231
+
232
+ const store = state({ count: 5 });
233
+
234
+ const doubled = computed(() => store.count * 2);
235
+ console.log(doubled.value); // 10
236
+
237
+ store.count = 10;
238
+ // After microtask:
239
+ console.log(doubled.value); // 20 (auto-updated)
240
+
241
+ // Subscribe to changes
242
+ const unsub = doubled.subscribe(value => {
243
+ console.log('Doubled:', value);
244
+ });
245
+
246
+ // Cleanup
247
+ doubled.dispose();
248
+ unsub();
249
+ ```
250
+
251
+ **Features:**
252
+ - ✅ Automatic dependency tracking using `effect()`
253
+ - ✅ Cached values (only recomputes when dependencies change)
254
+ - ✅ Subscribe to changes with `.subscribe(callback)`
255
+ - ✅ Cleanup with `.dispose()`
256
+ - ✅ Error handling (sets to undefined on error)
257
+
258
+ ### `watch(store, key, callback)`
259
+
260
+ Alias for `$subscribe` - observes changes to a specific state key.
261
+
262
+ ```javascript
263
+ import { state } from 'lume-js';
264
+ import { watch } from 'lume-js/addons';
265
+
266
+ const store = state({ count: 0 });
267
+
268
+ const unwatch = watch(store, 'count', (value) => {
269
+ console.log('Count is now:', value);
270
+ });
271
+
272
+ // Cleanup
273
+ unwatch();
274
+ ```
275
+
276
+ **Note:** `watch()` is just a convenience wrapper around `store.$subscribe()`. Use whichever feels more natural.
164
277
 
165
278
  ---
166
279
 
@@ -262,14 +375,65 @@ window.addEventListener('beforeunload', () => {
262
375
  <input type="checkbox" data-bind="user.settings.notifications">
263
376
  ```
264
377
 
378
+ ### Using Effects for Auto-Updates
379
+
380
+ ```javascript
381
+ import { state, effect } from 'lume-js';
382
+
383
+ const store = state({
384
+ firstName: 'Alice',
385
+ lastName: 'Smith'
386
+ });
387
+
388
+ // Auto-update title when name changes
389
+ effect(() => {
390
+ document.title = `${store.firstName} ${store.lastName}`;
391
+ });
392
+
393
+ store.firstName = 'Bob';
394
+ // Title automatically updates to "Bob Smith" in next microtask
395
+ ```
396
+
397
+ ### Computed Values
398
+
399
+ ```javascript
400
+ import { state } from 'lume-js';
401
+ import { computed } from 'lume-js/addons';
402
+
403
+ const cart = state({
404
+ items: state([
405
+ state({ price: 10, quantity: 2 }),
406
+ state({ price: 15, quantity: 1 })
407
+ ])
408
+ });
409
+
410
+ const total = computed(() => {
411
+ return cart.items.reduce((sum, item) =>
412
+ sum + (item.price * item.quantity), 0
413
+ );
414
+ });
415
+
416
+ console.log(total.value); // 35
417
+
418
+ cart.items[0].quantity = 3;
419
+ // After microtask:
420
+ console.log(total.value); // 45
421
+ ```
422
+
265
423
  ### Integration with GSAP
266
424
 
267
425
  ```javascript
268
426
  import gsap from 'gsap';
269
- import { state } from 'lume-js';
427
+ import { state, effect } from 'lume-js';
270
428
 
271
429
  const ui = state({ x: 0, y: 0 });
272
430
 
431
+ // Use effect for automatic animation updates
432
+ effect(() => {
433
+ gsap.to('.box', { x: ui.x, y: ui.y, duration: 0.5 });
434
+ });
435
+
436
+ // Or use $subscribe
273
437
  const unsubX = ui.$subscribe('x', (value) => {
274
438
  gsap.to('.box', { x: value, duration: 0.5 });
275
439
  });
@@ -283,17 +447,28 @@ window.addEventListener('beforeunload', () => unsubX());
283
447
  ### Cleanup Pattern (Important!)
284
448
 
285
449
  ```javascript
450
+ import { state, effect, bindDom } from 'lume-js';
451
+ import { computed } from 'lume-js/addons';
452
+
286
453
  const store = state({ data: [] });
287
454
  const cleanup = bindDom(root, store);
288
455
 
289
456
  const unsub1 = store.$subscribe('data', handleData);
290
457
  const unsub2 = store.$subscribe('status', handleStatus);
291
458
 
459
+ const effectCleanup = effect(() => {
460
+ console.log('Data length:', store.data.length);
461
+ });
462
+
463
+ const total = computed(() => store.data.length * 2);
464
+
292
465
  // Cleanup when component unmounts
293
466
  function destroy() {
294
- cleanup(); // Remove DOM bindings
295
- unsub1(); // Remove subscription 1
296
- unsub2(); // Remove subscription 2
467
+ cleanup(); // Remove DOM bindings
468
+ unsub1(); // Remove subscription 1
469
+ unsub2(); // Remove subscription 2
470
+ effectCleanup(); // Stop effect
471
+ total.dispose(); // Stop computed
297
472
  }
298
473
 
299
474
  // For SPA frameworks
@@ -380,25 +555,27 @@ document.addEventListener('click', () => store.clicks++);
380
555
 
381
556
  ---
382
557
 
383
- ## What's New in v0.3.0?
384
-
385
- ### Breaking Changes
386
- - ✅ `subscribe` `$subscribe` (restored from v0.1.0)
387
- - ✅ `$subscribe` now returns unsubscribe function
388
-
389
- ### New Features
390
- - ✅ TypeScript definitions (`index.d.ts`)
391
- - ✅ `bindDom()` returns cleanup function
558
+ ## What's New
559
+
560
+ ### Core Features
561
+ - ✅ **Automatic dependency tracking** - `effect()` automatically tracks which state properties are accessed
562
+ - ✅ **Per-state batching** - Each state object maintains its own microtask flush for optimal performance
563
+ - ✅ **Effect deduplication** - Effects only run once per flush cycle, even if multiple dependencies change
564
+ - **TypeScript support** - Full type definitions in `index.d.ts`
565
+ - ✅ **Cleanup functions** - All reactive APIs return cleanup/unsubscribe functions
566
+
567
+ ### Addons
568
+ - ✅ **`computed()`** - Memoized computed values with automatic dependency tracking
569
+ - ✅ **`watch()`** - Convenience alias for `$subscribe`
570
+
571
+ ### API Design
572
+ - ✅ `state()` - Create reactive state with automatic tracking support
573
+ - ✅ `effect()` - Core reactivity primitive (automatic dependency collection)
574
+ - ✅ `bindDom()` - DOM binding with two-way sync for form inputs
575
+ - ✅ `$subscribe()` - Manual subscriptions (calls immediately with current value)
576
+ - ✅ All functions return cleanup/unsubscribe functions
392
577
  - ✅ Better error handling with `[Lume.js]` prefix
393
- - ✅ Input validation (only plain objects)
394
- - ✅ Only triggers on actual value changes
395
- - ✅ Support for checkboxes, radio buttons, number inputs
396
- - ✅ Comprehensive example in `/examples/comprehensive/`
397
-
398
- ### Bug Fixes
399
- - ✅ Fixed memory leaks (no cleanup in v0.2.x)
400
- - ✅ Fixed addon examples (used wrong API)
401
- - ✅ Better path resolution with detailed errors
578
+ - ✅ Input validation on all public APIs
402
579
 
403
580
  ---
404
581
 
@@ -413,6 +590,39 @@ document.addEventListener('click', () => store.clicks++);
413
590
 
414
591
  ---
415
592
 
593
+ ## Testing
594
+
595
+ Lume.js uses [Vitest](https://vitest.dev) with a jsdom environment. The test suite mirrors the source tree: files under `tests/core/**` map to `src/core/**`, and `tests/addons/**` map to `src/addons/**`.
596
+
597
+ ```bash
598
+ # Run tests
599
+ npm test
600
+
601
+ # Watch mode
602
+ npm run test:watch
603
+
604
+ # Coverage with HTML report in ./coverage
605
+ npm run coverage
606
+ ```
607
+
608
+ **Import alias:** Tests use an alias so you can import from `src/...` without relative `../../` paths.
609
+
610
+ ```js
611
+ // vitest.config.js
612
+ resolve: {
613
+ alias: {
614
+ src: fileURLToPath(new URL('./src', import.meta.url))
615
+ }
616
+ }
617
+ ```
618
+
619
+ **Current coverage:**
620
+ - 100% statements, functions, and lines
621
+ - 100% branches (including edge-case paths)
622
+ - 37 tests covering core behavior, addons, inputs (text/checkbox/radio/number/range/select/textarea), nested state, and cleanup semantics
623
+
624
+ ---
625
+
416
626
  ## Contributing
417
627
 
418
628
  We welcome contributions! Please:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lume-js",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Minimal reactive state management using only standard JavaScript and HTML - no custom syntax, no build step required",
5
5
  "main": "src/index.js",
6
6
  "types": "src/index.d.ts",
@@ -8,7 +8,10 @@
8
8
  "scripts": {
9
9
  "dev": "vite",
10
10
  "build": "echo 'No build step needed - zero-runtime library!'",
11
- "preview": "vite preview"
11
+ "size": "node scripts/check-size.js",
12
+ "test": "vitest run",
13
+ "test:watch": "vitest",
14
+ "coverage": "vitest run --coverage"
12
15
  },
13
16
  "files": [
14
17
  "src",
@@ -45,7 +48,10 @@
45
48
  },
46
49
  "homepage": "https://github.com/sathvikc/lume-js#readme",
47
50
  "devDependencies": {
48
- "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"
49
55
  },
50
56
  "engines": {
51
57
  "node": ">=20.19.0"
@@ -1,53 +1,136 @@
1
1
  /**
2
- * computed - creates a derived value based on state
2
+ * Lume-JS Computed Addon
3
3
  *
4
- * NOTE: This is a basic implementation. For production use,
5
- * consider more robust solutions with automatic dependency tracking.
4
+ * Creates computed values that automatically update when dependencies change.
5
+ * Uses core effect() for automatic dependency tracking.
6
6
  *
7
- * @param {Function} fn - function that computes value from state
8
- * @returns {Object} - { value, recompute, subscribe }
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: 0 });
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(val => console.log('Doubled:', val));
46
+ * const unsub = doubled.subscribe(value => {
47
+ * console.log('Doubled changed to:', value);
48
+ * });
16
49
  *
17
- * // Manually trigger recomputation after state changes
18
- * store.$subscribe('count', () => doubled.recompute());
50
+ * @example
51
+ * // Cleanup
52
+ * doubled.dispose();
19
53
  */
20
54
  export function computed(fn) {
21
- let value;
22
- let dirty = true;
23
- const subscribers = new Set();
55
+ if (typeof fn !== 'function') {
56
+ throw new Error('computed() requires a function');
57
+ }
24
58
 
25
- const recalc = () => {
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
- value = fn();
28
- } catch (err) {
29
- console.error("[computed] Error computing value:", err);
30
- 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
+ }
31
86
  }
32
- dirty = false;
33
- subscribers.forEach(cb => cb(value));
34
- };
35
-
87
+ });
88
+
36
89
  return {
90
+ /**
91
+ * Get the current computed value
92
+ */
37
93
  get value() {
38
- if (dirty) recalc();
39
- return value;
94
+ if (!isInitialized) {
95
+ throw new Error('Computed value accessed before initialization');
96
+ }
97
+ return cachedValue;
40
98
  },
41
- recompute: () => {
42
- dirty = true;
43
- recalc();
44
- },
45
- subscribe: cb => {
46
- subscribers.add(cb);
47
- // Immediately notify subscriber with current value
48
- if (!dirty) cb(value);
49
- else recalc(); // Compute first time
50
- 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
+ };
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
  }
@@ -35,7 +35,7 @@ export function bindDom(root, store) {
35
35
  }
36
36
 
37
37
  const nodes = root.querySelectorAll("[data-bind]");
38
- const unsubscribers = [];
38
+ const cleanups = [];
39
39
 
40
40
  nodes.forEach(el => {
41
41
  const bindPath = el.getAttribute("data-bind");
@@ -61,24 +61,24 @@ export function bindDom(root, store) {
61
61
  return;
62
62
  }
63
63
 
64
- // Subscribe to changes
65
- const unsub = target.$subscribe(lastKey, val => {
64
+ // Subscribe to changes - receives already-batched notifications
65
+ const unsubscribe = target.$subscribe(lastKey, val => {
66
66
  updateElement(el, val);
67
67
  });
68
-
69
- unsubscribers.push(unsub);
68
+ cleanups.push(unsubscribe);
70
69
 
71
70
  // Two-way binding for form inputs
72
71
  if (isFormInput(el)) {
73
- el.addEventListener("input", e => {
72
+ const handler = e => {
74
73
  target[lastKey] = getInputValue(e.target);
75
- });
74
+ };
75
+ el.addEventListener("input", handler);
76
+ cleanups.push(() => el.removeEventListener("input", handler));
76
77
  }
77
78
  });
78
79
 
79
- // Return cleanup function
80
80
  return () => {
81
- unsubscribers.forEach(unsub => unsub());
81
+ cleanups.forEach(cleanup => cleanup());
82
82
  };
83
83
  }
84
84
 
@@ -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,6 +20,9 @@
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
  *
@@ -30,26 +36,93 @@ export function state(obj) {
30
36
  }
31
37
 
32
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;
33
42
 
34
- // Notify subscribers of a key
35
- function notify(key, val) {
36
- if (listeners[key]) {
37
- listeners[key].forEach(fn => fn(val));
38
- }
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
+ });
39
75
  }
40
76
 
41
77
  const proxy = new Proxy(obj, {
42
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
+
43
113
  return target[key];
44
114
  },
115
+
45
116
  set(target, key, value) {
46
117
  const oldValue = target[key];
118
+ if (oldValue === value) return true;
119
+
47
120
  target[key] = value;
48
121
 
49
- // Only notify if value actually changed
50
- if (oldValue !== value) {
51
- notify(key, value);
52
- }
122
+ // Batch notifications at the state level (per-state, not global)
123
+ pendingNotifications.set(key, value);
124
+ scheduleFlush();
125
+
53
126
  return true;
54
127
  }
55
128
  });
@@ -71,7 +144,7 @@ export function state(obj) {
71
144
  if (!listeners[key]) listeners[key] = [];
72
145
  listeners[key].push(fn);
73
146
 
74
- // Call immediately with current value
147
+ // Call immediately with current value (NOT batched)
75
148
  fn(proxy[key]);
76
149
 
77
150
  // Return unsubscribe function
package/src/index.d.ts CHANGED
@@ -85,4 +85,31 @@ export function state<T extends object>(obj: T): ReactiveState<T>;
85
85
  export function bindDom(
86
86
  root: HTMLElement,
87
87
  store: ReactiveState<any>
88
- ): Unsubscribe;
88
+ ): Unsubscribe;
89
+
90
+ /**
91
+ * Create an effect that automatically tracks dependencies
92
+ *
93
+ * The effect runs immediately and re-runs when any accessed state properties change.
94
+ * Only tracks properties that are actually accessed during execution.
95
+ *
96
+ * @param fn - Function to run reactively
97
+ * @returns Cleanup function to stop the effect
98
+ * @throws {Error} If fn is not a function
99
+ *
100
+ * @example
101
+ * ```typescript
102
+ * const store = state({ count: 0, name: 'Alice' });
103
+ *
104
+ * const cleanup = effect(() => {
105
+ * // Only tracks 'count' (name not accessed)
106
+ * console.log(`Count: ${store.count}`);
107
+ * });
108
+ *
109
+ * store.count = 5; // Effect re-runs
110
+ * store.name = 'Bob'; // Effect does NOT re-run
111
+ *
112
+ * cleanup(); // Stop the effect
113
+ * ```
114
+ */
115
+ export function effect(fn: () => void): Unsubscribe;
package/src/index.js CHANGED
@@ -4,10 +4,12 @@
4
4
  * Exposes:
5
5
  * - state(): create reactive state
6
6
  * - bindDom(): zero-runtime DOM binding
7
+ * - effect(): reactive effect with automatic dependency tracking
7
8
  *
8
9
  * Usage:
9
- * import { state, bindDom } from "lume-js";
10
+ * import { state, bindDom, effect } from "lume-js";
10
11
  */
11
12
 
12
13
  export { state } from "./core/state.js";
13
14
  export { bindDom } from "./core/bindDom.js";
15
+ export { effect } from "./core/effect.js";