lume-js 1.0.0 → 2.0.0-alpha.2

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.
@@ -18,65 +18,58 @@
18
18
  * - Set to null/false to disable, or provide custom functions
19
19
  * - Export utilities so you can wrap/extend them
20
20
  *
21
- * Usage:
22
- * import { repeat } from "lume-js/addons/repeat.js";
23
- *
24
- * // ⚠️ IMPORTANT: Arrays must be updated immutably!
25
- * // store.items.push(x) // ❌ Won't trigger update
26
- * // store.items = [...items] // Triggers update
27
- *
28
- * // Basic usage (focus & scroll preservation enabled by default)
21
+ * ⚠️ IMPORTANT: Arrays must be updated immutably!
22
+ * store.items.push(x) // Won't trigger update
23
+ * store.items = [...items] // ✅ Triggers update
24
+ *
25
+ * ═══════════════════════════════════════════════════════════════════════
26
+ * PATTERN 1: Simple (render only) - for simple cases or backward compat
27
+ * ═══════════════════════════════════════════════════════════════════════
28
+ *
29
29
  * repeat('#list', store, 'todos', {
30
30
  * key: todo => todo.id,
31
31
  * render: (todo, el) => {
32
- * if (!el.dataset.init) {
33
- * el.innerHTML = `<input value="${todo.text}">`;
34
- * el.dataset.init = 'true';
35
- * }
32
+ * el.textContent = todo.name; // Called on every update
36
33
  * }
37
34
  * });
38
- *
39
- * // Disable all preservation (bare-bones repeat)
40
- * repeat('#list', store, 'items', {
41
- * key: item => item.id,
42
- * render: (item, el) => { el.textContent = item.name; },
43
- * preserveFocus: null,
44
- * preserveScroll: null
45
- * });
46
- *
47
- * // Custom focus preservation
48
- * repeat('#list', store, 'items', {
49
- * key: item => item.id,
50
- * render: (item, el) => { ... },
51
- * preserveFocus: (container) => {
52
- * // Your custom logic
53
- * const state = { focused: document.activeElement };
54
- * return () => state.focused?.focus();
35
+ *
36
+ * ═══════════════════════════════════════════════════════════════════════
37
+ * PATTERN 2: Clean separation (create + update) - recommended
38
+ * ═══════════════════════════════════════════════════════════════════════
39
+ *
40
+ * repeat('#list', store, 'todos', {
41
+ * key: todo => todo.id,
42
+ * create: (todo, el) => {
43
+ * // Called ONCE when element is created - build DOM structure
44
+ * el.innerHTML = `<span class="name"></span><button>Delete</button>`;
45
+ * el.querySelector('button').onclick = () => deleteTodo(todo.id);
46
+ * },
47
+ * update: (todo, el, index, { isFirstRender }) => {
48
+ * // Called on every update - bind data
49
+ * // isFirstRender = true on initial render, false on subsequent
50
+ * // Skipped if same object reference (optimization)
51
+ * el.querySelector('.name').textContent = todo.name;
55
52
  * }
56
53
  * });
57
- *
58
- * // Mix built-in and custom
59
- * import { defaultFocusPreservation, defaultScrollPreservation } from "lume-js/addons/repeat.js";
54
+ *
55
+ * ═══════════════════════════════════════════════════════════════════════
56
+ * ADVANCED: Custom preservation strategies
57
+ * ═══════════════════════════════════════════════════════════════════════
58
+ *
59
+ * import { defaultFocusPreservation, defaultScrollPreservation } from "lume-js/addons";
60
60
  *
61
61
  * repeat('#list', store, 'items', {
62
62
  * key: item => item.id,
63
- * render: (item, el) => { ... },
64
- * preserveFocus: defaultFocusPreservation, // use built-in
63
+ * create: (item, el) => { ... },
64
+ * update: (item, el) => { ... },
65
+ * preserveFocus: null, // disable focus preservation
65
66
  * preserveScroll: (container, context) => {
66
- * // wrap/extend built-in
67
67
  * const restore = defaultScrollPreservation(container, context);
68
- * return () => {
69
- * restore();
70
- * console.log('Scroll restored!');
71
- * };
68
+ * return () => { restore(); console.log('Scroll restored!'); };
72
69
  * }
73
70
  * });
74
71
  */
75
72
 
76
- // ============================================================================
77
- // PRESERVATION UTILITIES (Exported for customization)
78
- // ============================================================================
79
-
80
73
  /**
81
74
  * Default focus preservation strategy
82
75
  * Saves activeElement and selection state before DOM updates
@@ -158,10 +151,6 @@ export function defaultScrollPreservation(container, context = {}) {
158
151
  };
159
152
  }
160
153
 
161
- // ============================================================================
162
- // MAIN REPEAT FUNCTION
163
- // ============================================================================
164
-
165
154
  /**
166
155
  * Efficiently render a list with element reuse
167
156
  *
@@ -170,7 +159,9 @@ export function defaultScrollPreservation(container, context = {}) {
170
159
  * @param {string} arrayKey - Key in store containing the array
171
160
  * @param {Object} options - Configuration
172
161
  * @param {Function} options.key - Function to extract unique key: (item) => key
173
- * @param {Function} options.render - Function to render item: (item, element, index) => void
162
+ * @param {Function} [options.render] - Function to render item (called for all items): (item, element, index) => void
163
+ * @param {Function} [options.create] - Function for new elements only: (item, element, index) => void
164
+ * @param {Function} [options.update] - Function for data binding: (item, element, index, { isFirstRender }) => void. Skipped if same item reference AND same index.
174
165
  * @param {string|Function} [options.element='div'] - Element tag name or factory function
175
166
  * @param {Function|null} [options.preserveFocus=defaultFocusPreservation] - Focus preservation strategy (null to disable)
176
167
  * @param {Function|null} [options.preserveScroll=defaultScrollPreservation] - Scroll preservation strategy (null to disable)
@@ -180,6 +171,8 @@ export function repeat(container, store, arrayKey, options) {
180
171
  const {
181
172
  key,
182
173
  render,
174
+ create,
175
+ update,
183
176
  element = 'div',
184
177
  preserveFocus = defaultFocusPreservation,
185
178
  preserveScroll = defaultScrollPreservation
@@ -200,12 +193,16 @@ export function repeat(container, store, arrayKey, options) {
200
193
  throw new Error('[Lume.js] repeat(): options.key must be a function');
201
194
  }
202
195
 
203
- if (typeof render !== 'function') {
204
- throw new Error('[Lume.js] repeat(): options.render must be a function');
196
+ if (typeof render !== 'function' && typeof create !== 'function') {
197
+ throw new Error('[Lume.js] repeat(): options.render or options.create must be a function');
205
198
  }
206
199
 
207
200
  // key -> HTMLElement
208
201
  const elementsByKey = new Map();
202
+ // key -> previous item (for reference comparison)
203
+ const prevItemsByKey = new Map();
204
+ // key -> previous index (for reorder detection)
205
+ const prevIndexByKey = new Map();
209
206
  const seenKeys = new Set();
210
207
 
211
208
  function createElement() {
@@ -254,24 +251,38 @@ export function repeat(container, store, arrayKey, options) {
254
251
  nextKeys.add(k);
255
252
 
256
253
  let el = elementsByKey.get(k);
257
- const isNew = !el;
254
+ const isFirstRender = !el;
258
255
 
259
- if (isNew) {
256
+ if (isFirstRender) {
260
257
  el = createElement();
261
258
  elementsByKey.set(k, el);
262
259
  }
263
260
 
264
261
  try {
265
- if (isNew) {
266
- el.__lume_new = true;
262
+ // Call create for new elements (DOM structure)
263
+ if (isFirstRender && create) {
264
+ create(item, el, i);
265
+ }
266
+
267
+ // Call update for data binding (new and existing elements)
268
+ // Skip if same item reference AND same index (optimization)
269
+ const prevItem = prevItemsByKey.get(k);
270
+ const prevIndex = prevIndexByKey.get(k);
271
+ if (update) {
272
+ if (prevItem !== item || prevIndex !== i) {
273
+ update(item, el, i, { isFirstRender });
274
+ }
275
+ } else if (render) {
276
+ // Backward compatibility: render handles both create and update
277
+ render(item, el, i);
267
278
  }
268
279
 
269
- render(item, el, i);
280
+ // Store reference and index for next comparison
281
+ prevItemsByKey.set(k, item);
282
+ prevIndexByKey.set(k, i);
270
283
 
271
284
  } catch (err) {
272
285
  console.error(`[Lume.js] repeat(): error rendering key "${k}"`, err);
273
- } finally {
274
- delete el.__lume_new;
275
286
  }
276
287
 
277
288
  nextEls.push(el);
@@ -298,12 +309,13 @@ export function repeat(container, store, arrayKey, options) {
298
309
  ptr = next;
299
310
  }
300
311
 
301
- // Clean map: remove keys not in nextKeys
302
- // Iterate over elementsByKey entries and delete if not in nextKeys
312
+ // Clean maps: remove keys not in nextKeys
303
313
  if (elementsByKey.size !== nextKeys.size) {
304
314
  for (const k of elementsByKey.keys()) {
305
315
  if (!nextKeys.has(k)) {
306
316
  elementsByKey.delete(k);
317
+ prevItemsByKey.delete(k);
318
+ prevIndexByKey.delete(k);
307
319
  }
308
320
  }
309
321
  }
@@ -328,6 +340,8 @@ export function repeat(container, store, arrayKey, options) {
328
340
  return () => {
329
341
  containerEl.replaceChildren();
330
342
  elementsByKey.clear();
343
+ prevItemsByKey.clear();
344
+ prevIndexByKey.clear();
331
345
  seenKeys.clear();
332
346
  };
333
347
  }
@@ -337,6 +351,8 @@ export function repeat(container, store, arrayKey, options) {
337
351
  // Clear DOM elements (replaceChildren is faster than loop)
338
352
  containerEl.replaceChildren();
339
353
  elementsByKey.clear();
354
+ prevItemsByKey.clear();
355
+ prevIndexByKey.clear();
340
356
  seenKeys.clear();
341
357
  };
342
358
  }
@@ -52,9 +52,12 @@ export function bindDom(root, store, options = {}) {
52
52
  const nodes = root.querySelectorAll("[data-bind]");
53
53
  const cleanups = [];
54
54
 
55
+ // Map for event delegation: element → { target, lastKey }
56
+ const bindingMap = new Map();
57
+
55
58
  nodes.forEach(el => {
56
59
  const bindPath = el.getAttribute("data-bind");
57
-
60
+
58
61
  if (!bindPath) {
59
62
  console.warn('[Lume.js] Empty data-bind attribute found', el);
60
63
  return;
@@ -82,18 +85,27 @@ export function bindDom(root, store, options = {}) {
82
85
  });
83
86
  cleanups.push(unsubscribe);
84
87
 
85
- // Two-way binding for form inputs
88
+ // Store binding info for event delegation (form inputs only)
86
89
  if (isFormInput(el)) {
87
- const handler = e => {
88
- target[lastKey] = getInputValue(e.target);
89
- };
90
- el.addEventListener("input", handler);
91
- cleanups.push(() => el.removeEventListener("input", handler));
90
+ bindingMap.set(el, { target, lastKey });
92
91
  }
93
92
  });
94
93
 
94
+ // Event delegation: single listener on root for all form inputs
95
+ const delegatedHandler = e => {
96
+ const el = e.target;
97
+ const binding = bindingMap.get(el);
98
+ if (binding) {
99
+ binding.target[binding.lastKey] = getInputValue(el);
100
+ }
101
+ };
102
+
103
+ root.addEventListener("input", delegatedHandler);
104
+ cleanups.push(() => root.removeEventListener("input", delegatedHandler));
105
+
95
106
  return () => {
96
107
  cleanups.forEach(cleanup => cleanup());
108
+ bindingMap.clear();
97
109
  };
98
110
  };
99
111
 
@@ -104,7 +116,7 @@ export function bindDom(root, store, options = {}) {
104
116
  cleanup = performBinding();
105
117
  };
106
118
  document.addEventListener('DOMContentLoaded', onReady, { once: true });
107
-
119
+
108
120
  // Return cleanup function that handles both cases
109
121
  return () => {
110
122
  if (cleanup) {
@@ -165,7 +177,7 @@ function getInputValue(el) {
165
177
  * @private
166
178
  */
167
179
  function isFormInput(el) {
168
- return el.tagName === "INPUT" ||
169
- el.tagName === "TEXTAREA" ||
170
- el.tagName === "SELECT";
180
+ return el.tagName === "INPUT" ||
181
+ el.tagName === "TEXTAREA" ||
182
+ el.tagName === "SELECT";
171
183
  }
@@ -1,100 +1,140 @@
1
1
  /**
2
2
  * Lume-JS Effect
3
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.
4
+ * Reactive effects with two modes:
5
+ * 1. Auto-tracking (default): Tracks dependencies automatically
6
+ * 2. Explicit deps: You specify exactly what triggers re-runs
7
7
  *
8
8
  * Part of core because it's fundamental to modern reactivity.
9
9
  *
10
10
  * Usage:
11
11
  * import { effect } from "lume-js";
12
12
  *
13
+ * // Auto-tracking mode (existing behavior)
13
14
  * effect(() => {
14
15
  * console.log('Count is:', store.count);
15
16
  * // Automatically re-runs when store.count changes
16
17
  * });
18
+ *
19
+ * // Explicit deps mode (new - no magic)
20
+ * effect(() => {
21
+ * console.log('Count is:', store.count);
22
+ * }, [[store, 'count']]); // Only re-runs when store.count changes
17
23
  *
18
24
  * Features:
19
- * - Automatic dependency collection
20
- * - Dynamic dependencies (tracks what you actually access)
25
+ * - Automatic dependency collection (default)
26
+ * - Explicit dependencies for side-effects
21
27
  * - Returns cleanup function
22
- * - Plays nicely with per-state batching (no global scheduler)
23
- *
28
+ * - Compatible with per-state batching
24
29
  */
25
30
 
26
31
  /**
27
- * Creates an effect that automatically tracks dependencies
32
+ * Creates an effect that runs reactively
28
33
  *
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
34
  * @param {function} fn - Function to run reactively
35
+ * @param {Array<[object, string]>} [deps] - Optional explicit dependencies as [store, key] tuples
34
36
  * @returns {function} Cleanup function to stop the effect
35
37
  *
36
38
  * @example
37
- * const store = state({ count: 0, name: 'Alice' });
38
- *
39
- * const cleanup = effect(() => {
40
- * // Only tracks 'count' (name not accessed)
39
+ * // Auto-tracking (default)
40
+ * const store = state({ count: 0 });
41
+ * effect(() => {
41
42
  * document.title = `Count: ${store.count}`;
42
43
  * });
43
44
  *
44
- * store.count = 5; // Effect re-runs
45
- * store.name = 'Bob'; // Effect does NOT re-run
46
- *
47
- * cleanup(); // Stop tracking
45
+ * @example
46
+ * // Explicit deps (no magic)
47
+ * effect(() => {
48
+ * analytics.log(store.count); // Won't track store.count automatically
49
+ * }, [[store, 'count']]); // Explicit: only re-run on store.count
48
50
  */
49
- export function effect(fn) {
51
+ export function effect(fn, deps) {
50
52
  if (typeof fn !== 'function') {
51
53
  throw new Error('effect() requires a function');
52
54
  }
53
55
 
54
56
  const cleanups = [];
55
- let isRunning = false; // Prevent infinite recursion
56
-
57
+ let isRunning = false;
58
+
57
59
  /**
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.
60
+ * Execute the effect function
63
61
  */
64
62
  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;
63
+ if (isRunning) return;
81
64
  isRunning = true;
82
-
65
+
83
66
  try {
84
- // Run the effect function (this triggers state getters)
85
67
  fn();
86
68
  } catch (error) {
87
69
  console.error('[Lume.js effect] Error in effect:', error);
88
70
  throw error;
89
71
  } finally {
90
- // Always clean up, even if error
91
- globalThis.__LUME_CURRENT_EFFECT__ = undefined;
92
72
  isRunning = false;
93
73
  }
94
74
  };
95
-
96
- // Run immediately to collect initial dependencies
97
- execute();
75
+
76
+ // EXPLICIT DEPS MODE: deps array provided
77
+ if (Array.isArray(deps)) {
78
+ // Subscribe to each [store, key1, key2, ...] tuple explicitly
79
+ for (const dep of deps) {
80
+ if (Array.isArray(dep) && dep.length >= 2) {
81
+ const [store, ...keys] = dep;
82
+ if (store && typeof store.$subscribe === 'function') {
83
+ // Subscribe to each key in this tuple
84
+ for (const key of keys) {
85
+ // $subscribe calls immediately, then on changes
86
+ // We want: call execute immediately once, then on changes
87
+ let isFirst = true;
88
+ const unsub = store.$subscribe(key, () => {
89
+ if (isFirst) {
90
+ isFirst = false;
91
+ return; // Skip first call, we'll run execute() below
92
+ }
93
+ execute();
94
+ });
95
+ cleanups.push(unsub);
96
+ }
97
+ }
98
+ }
99
+ }
100
+ // Run immediately
101
+ execute();
102
+ }
103
+ // AUTO-TRACKING MODE: no deps (existing behavior)
104
+ else {
105
+ const executeWithTracking = () => {
106
+ if (isRunning) return;
107
+
108
+ // Clean up previous subscriptions
109
+ cleanups.forEach(cleanup => cleanup());
110
+ cleanups.length = 0;
111
+
112
+ // Create effect context for tracking
113
+ const effectContext = {
114
+ fn,
115
+ cleanups,
116
+ execute: executeWithTracking,
117
+ tracking: {}
118
+ };
119
+
120
+ // Set as current effect (for state.js to detect)
121
+ globalThis.__LUME_CURRENT_EFFECT__ = effectContext;
122
+ isRunning = true;
123
+
124
+ try {
125
+ fn();
126
+ } catch (error) {
127
+ console.error('[Lume.js effect] Error in effect:', error);
128
+ throw error;
129
+ } finally {
130
+ globalThis.__LUME_CURRENT_EFFECT__ = undefined;
131
+ isRunning = false;
132
+ }
133
+ };
134
+
135
+ // Run immediately to collect initial dependencies
136
+ executeWithTracking();
137
+ }
98
138
 
99
139
  // Return cleanup function
100
140
  return () => {
package/src/core/state.js CHANGED
@@ -4,6 +4,7 @@
4
4
  * Provides minimal reactive state with standard JavaScript.
5
5
  * Features automatic microtask batching for performance.
6
6
  * Supports automatic dependency tracking for effects.
7
+ * Supports optional plugin system for extensibility (v2.0+).
7
8
  *
8
9
  * Features:
9
10
  * - Lightweight and Go-style
@@ -12,47 +13,93 @@
12
13
  * - Cleanup with unsubscribe
13
14
  * - Per-state microtask batching for writes
14
15
  * - Effect dependency tracking support (deduped per state flush)
16
+ * - Plugin system for custom behaviors (v2.0+)
15
17
  *
16
18
  * Usage:
17
19
  * import { state } from "lume-js";
20
+ *
21
+ * // Basic usage (v1.x compatible)
18
22
  * const store = state({ count: 0 });
19
23
  * const unsub = store.$subscribe("count", val => console.log(val));
20
24
  * unsub(); // cleanup
25
+ *
26
+ * // With plugins (v2.0+)
27
+ * const store = state(
28
+ * { count: 0 },
29
+ * { plugins: [debugPlugin] }
30
+ * );
21
31
  */
22
32
 
23
33
  // Per-state batching – each state object maintains its own microtask flush.
24
34
  // This keeps effects simple and aligned with Lume's minimal philosophy.
25
35
 
26
36
  /**
27
- * Creates a reactive state object.
37
+ * Creates a reactive state object with optional plugin support.
28
38
  *
29
- * @param {Object} obj - Initial state object
39
+ * @param {Object} obj - Initial state object (must be plain object)
40
+ * @param {Object} [options] - Optional configuration
41
+ * @param {Array<Plugin>} [options.plugins] - Array of plugins to apply
30
42
  * @returns {Proxy} Reactive proxy with $subscribe method
43
+ *
44
+ * @example
45
+ * // Basic usage
46
+ * const store = state({ count: 0 });
47
+ *
48
+ * @example
49
+ * // With plugins
50
+ * const store = state(
51
+ * { count: 0 },
52
+ * {
53
+ * plugins: [
54
+ * {
55
+ * name: 'logger',
56
+ * onGet: (key, value) => {
57
+ * console.log(`GET ${key}:`, value);
58
+ * return value;
59
+ * }
60
+ * }
61
+ * ]
62
+ * }
63
+ * );
31
64
  */
32
65
  // Internal symbol used to mark reactive proxies (non-enumerable via Proxy trap)
33
66
  const REACTIVE_MARKER = Symbol('__LUME_REACTIVE__');
34
67
 
35
- export function state(obj) {
68
+ export function state(obj, options = {}) {
36
69
  // Validate input
37
70
  if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
38
71
  throw new Error('state() requires a plain object');
39
72
  }
40
73
 
74
+ // Extract plugins (minimal validation)
75
+ const plugins = options.plugins || [];
76
+
41
77
  const listeners = {};
42
78
  const pendingNotifications = new Map(); // Per-state pending changes
43
79
  const pendingEffects = new Set(); // Dedupe effects per state
44
80
  let flushScheduled = false;
45
81
 
82
+ // Call onInit hooks
83
+ for (const p of plugins) {
84
+ try {
85
+ p.onInit?.();
86
+ } catch (e) {
87
+ console.error(`[Lume.js] Plugin "${p.name}" error in onInit:`, e);
88
+ }
89
+ }
90
+
46
91
  /**
47
92
  * Schedule a single microtask flush for this state object.
48
93
  *
49
94
  * Flush order per state:
50
- * 1) Notify subscribers for changed keys (key → subscribers)
51
- * 2) Run each queued effect exactly once (Set-based dedupe)
95
+ * 1) Call plugin onNotify hooks for each changed key
96
+ * 2) Notify subscribers for changed keys (key → subscribers)
97
+ * 3) Run each queued effect exactly once (Set-based dedupe)
52
98
  *
53
99
  * Notes:
54
100
  * - Batching is per state; effects that depend on multiple states
55
101
  * may run once per state that changed (by design).
102
+ * - Plugin onNotify hooks run before subscribers (can observe before notification)
56
103
  */
57
104
  function scheduleFlush() {
58
105
  if (flushScheduled) return;
@@ -61,6 +108,17 @@ export function state(obj) {
61
108
  queueMicrotask(() => {
62
109
  flushScheduled = false;
63
110
 
111
+ // Plugin onNotify hooks (before subscribers)
112
+ for (const [key, value] of pendingNotifications) {
113
+ for (const p of plugins) {
114
+ try {
115
+ p.onNotify?.(key, value);
116
+ } catch (e) {
117
+ console.error(`[Lume.js] Plugin "${p.name}" error in onNotify:`, e);
118
+ }
119
+ }
120
+ }
121
+
64
122
  // Notify all subscribers of changed keys
65
123
  // Snapshot listeners array to handle unsubscribes during iteration
66
124
  for (const [key, value] of pendingNotifications) {
@@ -87,7 +145,21 @@ export function state(obj) {
87
145
  if (typeof key === 'string' && key.startsWith('$')) {
88
146
  return target[key];
89
147
  }
90
- // Support effect tracking
148
+
149
+ // Get original value
150
+ let value = target[key];
151
+
152
+ // Plugin onGet hooks run first (effects track key, not value)
153
+ for (const p of plugins) {
154
+ try {
155
+ const r = p.onGet?.(key, value);
156
+ if (r !== undefined) value = r;
157
+ } catch (e) {
158
+ console.error(`[Lume.js] Plugin "${p.name}" error in onGet:`, e);
159
+ }
160
+ }
161
+
162
+ // Effect tracking
91
163
  // Check if we're inside an effect context
92
164
  if (typeof globalThis.__LUME_CURRENT_EFFECT__ !== 'undefined') {
93
165
  const currentEffect = globalThis.__LUME_CURRENT_EFFECT__;
@@ -121,17 +193,30 @@ export function state(obj) {
121
193
  }
122
194
  }
123
195
 
124
- return target[key];
196
+ return value;
125
197
  },
126
198
 
127
199
  set(target, key, value) {
128
200
  const oldValue = target[key];
129
- if (oldValue === value) return true;
201
+ let newValue = value;
130
202
 
131
- target[key] = value;
203
+ // Plugin onSet hooks (chain pattern)
204
+ for (const p of plugins) {
205
+ try {
206
+ const r = p.onSet?.(key, newValue, oldValue);
207
+ if (r !== undefined) newValue = r;
208
+ } catch (e) {
209
+ console.error(`[Lume.js] Plugin "${p.name}" error in onSet:`, e);
210
+ }
211
+ }
212
+
213
+ // Skip update if value unchanged after plugin processing
214
+ if (oldValue === newValue) return true;
215
+
216
+ target[key] = newValue;
132
217
 
133
218
  // Batch notifications at the state level (per-state, not global)
134
- pendingNotifications.set(key, value);
219
+ pendingNotifications.set(key, newValue);
135
220
  scheduleFlush();
136
221
 
137
222
  return true;
@@ -152,6 +237,15 @@ export function state(obj) {
152
237
  throw new Error('Subscriber must be a function');
153
238
  }
154
239
 
240
+ // Plugin onSubscribe hooks
241
+ for (const p of plugins) {
242
+ try {
243
+ p.onSubscribe?.(key);
244
+ } catch (e) {
245
+ console.error(`[Lume.js] Plugin "${p.name}" error in onSubscribe:`, e);
246
+ }
247
+ }
248
+
155
249
  if (!listeners[key]) listeners[key] = [];
156
250
  listeners[key].push(fn);
157
251