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 +247 -37
- package/package.json +9 -3
- package/src/addons/computed.js +116 -33
- package/src/core/bindDom.js +9 -9
- package/src/core/effect.js +104 -0
- package/src/core/state.js +84 -11
- package/src/index.d.ts +28 -1
- package/src/index.js +3 -1
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)
|
|
8
|
-
[](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', () =>
|
|
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
|
-
**
|
|
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.
|
|
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
|
-
**
|
|
161
|
-
- Returns unsubscribe function
|
|
162
|
-
- Validates callback is a function
|
|
163
|
-
-
|
|
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();
|
|
295
|
-
unsub1();
|
|
296
|
-
unsub2();
|
|
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
|
|
384
|
-
|
|
385
|
-
###
|
|
386
|
-
- ✅ `
|
|
387
|
-
- ✅
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
- ✅
|
|
391
|
-
|
|
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
|
|
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
|
+
"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
|
-
"
|
|
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"
|
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
|
}
|
package/src/core/bindDom.js
CHANGED
|
@@ -35,7 +35,7 @@ export function bindDom(root, store) {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
const nodes = root.querySelectorAll("[data-bind]");
|
|
38
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
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";
|