lume-js 2.0.0 → 2.1.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 +6 -6
- package/dist/addons.min.mjs +1 -1
- package/dist/addons.mjs +61 -10
- package/dist/addons.mjs.map +1 -1
- package/dist/index.min.mjs +1 -1
- package/dist/index.mjs +2 -2
- package/dist/lume.global.js +1 -1
- package/dist/lume.global.js.map +1 -1
- package/dist/{shared-nXhT2Lh7.mjs → shared-Dcokqj5a.mjs} +17 -17
- package/dist/shared-Dcokqj5a.mjs.map +1 -0
- package/package.json +2 -1
- package/src/addons/repeat.js +71 -14
- package/src/core/state.js +21 -23
- package/dist/shared-nXhT2Lh7.mjs.map +0 -1
package/src/addons/repeat.js
CHANGED
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
* ═══════════════════════════════════════════════════════════════════════
|
|
36
36
|
* PATTERN 2: Clean separation (create + update) - recommended
|
|
37
37
|
* ═══════════════════════════════════════════════════════════════════════
|
|
38
|
-
*
|
|
38
|
+
*
|
|
39
39
|
* repeat('#list', store, 'todos', {
|
|
40
40
|
* key: todo => todo.id,
|
|
41
41
|
* create: (todo, el) => {
|
|
@@ -47,6 +47,11 @@
|
|
|
47
47
|
* btn.textContent = 'Delete';
|
|
48
48
|
* btn.onclick = () => deleteTodo(todo.id);
|
|
49
49
|
* el.appendChild(btn);
|
|
50
|
+
*
|
|
51
|
+
* // Return a cleanup function — called automatically when element is removed
|
|
52
|
+
* return () => {
|
|
53
|
+
* // Unsubscribe from external listeners, remove timers, etc.
|
|
54
|
+
* };
|
|
50
55
|
* },
|
|
51
56
|
* update: (todo, el, index, { isFirstRender }) => {
|
|
52
57
|
* // Called on every update - bind data
|
|
@@ -165,8 +170,9 @@ export function defaultScrollPreservation(container, context = {}) {
|
|
|
165
170
|
* @param {Object} options - Configuration
|
|
166
171
|
* @param {Function} options.key - Function to extract unique key: (item) => key
|
|
167
172
|
* @param {Function} [options.render] - Function to render item (called for all items): (item, element, index) => void
|
|
168
|
-
* @param {Function} [options.create] - Function for new elements only: (item, element, index) => void
|
|
173
|
+
* @param {Function} [options.create] - Function for new elements only: (item, element, index) => void | Function. If a function is returned, it is registered as the element's cleanup and called automatically when the element is removed (by list update or full cleanup).
|
|
169
174
|
* @param {Function} [options.update] - Function for data binding: (item, element, index, { isFirstRender }) => void. Skipped if same item reference AND same index.
|
|
175
|
+
* @param {Function} [options.remove] - Additional cleanup when element is removed: (item, element) => void. Called after any cleanup function returned by create(). Optional — prefer returning a cleanup from create() for automatic lifecycle management.
|
|
170
176
|
* @param {string|Function} [options.element='div'] - Element tag name or factory function
|
|
171
177
|
* @param {Function|null} [options.preserveFocus=defaultFocusPreservation] - Focus preservation strategy (null to disable)
|
|
172
178
|
* @param {Function|null} [options.preserveScroll=defaultScrollPreservation] - Scroll preservation strategy (null to disable)
|
|
@@ -179,6 +185,7 @@ export function repeat(container, store, arrayKey, options) {
|
|
|
179
185
|
render,
|
|
180
186
|
create,
|
|
181
187
|
update,
|
|
188
|
+
remove,
|
|
182
189
|
element = 'div',
|
|
183
190
|
preserveFocus = defaultFocusPreservation,
|
|
184
191
|
preserveScroll = defaultScrollPreservation
|
|
@@ -209,6 +216,8 @@ export function repeat(container, store, arrayKey, options) {
|
|
|
209
216
|
const prevItemsByKey = new Map();
|
|
210
217
|
// key -> previous index (for reorder detection)
|
|
211
218
|
const prevIndexByKey = new Map();
|
|
219
|
+
// key -> cleanup function returned by create()
|
|
220
|
+
const cleanupByKey = new Map();
|
|
212
221
|
const seenKeys = new Set();
|
|
213
222
|
|
|
214
223
|
function createElement() {
|
|
@@ -258,17 +267,17 @@ export function repeat(container, store, arrayKey, options) {
|
|
|
258
267
|
return;
|
|
259
268
|
}
|
|
260
269
|
|
|
261
|
-
// Only compute isReorder if scroll preservation needs it
|
|
270
|
+
// Only compute isReorder if scroll preservation needs it.
|
|
271
|
+
// Uses elementsByKey (previous state) and items directly — no Set allocations.
|
|
262
272
|
let isReorder = false;
|
|
263
|
-
if (preserveScroll) {
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
273
|
+
if (preserveScroll && elementsByKey.size === items.length) {
|
|
274
|
+
isReorder = true;
|
|
275
|
+
for (let i = 0; i < items.length; i++) {
|
|
276
|
+
if (!elementsByKey.has(key(items[i]))) { isReorder = false; break; }
|
|
277
|
+
}
|
|
268
278
|
}
|
|
269
279
|
|
|
270
280
|
seenKeys.clear();
|
|
271
|
-
const nextKeys = new Set();
|
|
272
281
|
const nextEls = [];
|
|
273
282
|
|
|
274
283
|
// Build ordered list of DOM nodes (created or reused)
|
|
@@ -281,7 +290,6 @@ export function repeat(container, store, arrayKey, options) {
|
|
|
281
290
|
continue;
|
|
282
291
|
}
|
|
283
292
|
seenKeys.add(k);
|
|
284
|
-
nextKeys.add(k);
|
|
285
293
|
|
|
286
294
|
let el = elementsByKey.get(k);
|
|
287
295
|
const isFirstRender = !el;
|
|
@@ -294,7 +302,10 @@ export function repeat(container, store, arrayKey, options) {
|
|
|
294
302
|
try {
|
|
295
303
|
// Call create for new elements (DOM structure)
|
|
296
304
|
if (isFirstRender && create) {
|
|
297
|
-
create(item, el, i);
|
|
305
|
+
const cleanup = create(item, el, i);
|
|
306
|
+
if (typeof cleanup === 'function') {
|
|
307
|
+
cleanupByKey.set(k, cleanup);
|
|
308
|
+
}
|
|
298
309
|
}
|
|
299
310
|
|
|
300
311
|
// Call update for data binding (new and existing elements)
|
|
@@ -324,13 +335,28 @@ export function repeat(container, store, arrayKey, options) {
|
|
|
324
335
|
applyPreservation(containerEl, () => {
|
|
325
336
|
reconcileDOM(containerEl, nextEls);
|
|
326
337
|
|
|
327
|
-
// Clean maps: remove keys not in
|
|
328
|
-
if (elementsByKey.size !==
|
|
338
|
+
// Clean maps: remove keys not in seenKeys (new state)
|
|
339
|
+
if (elementsByKey.size !== seenKeys.size) {
|
|
329
340
|
for (const k of elementsByKey.keys()) {
|
|
330
|
-
if (!
|
|
341
|
+
if (!seenKeys.has(k)) {
|
|
342
|
+
const el = elementsByKey.get(k);
|
|
343
|
+
const prevItem = prevItemsByKey.get(k);
|
|
344
|
+
// Call create-returned cleanup first, then remove callback
|
|
345
|
+
const cleanup = cleanupByKey.get(k);
|
|
346
|
+
if (typeof cleanup === 'function') {
|
|
347
|
+
try {
|
|
348
|
+
cleanup();
|
|
349
|
+
} catch (err) {
|
|
350
|
+
logError(`[Lume.js] repeat(): cleanup error for key "${k}":`, err);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
if (typeof remove === 'function' && el) {
|
|
354
|
+
remove(prevItem, el);
|
|
355
|
+
}
|
|
331
356
|
elementsByKey.delete(k);
|
|
332
357
|
prevItemsByKey.delete(k);
|
|
333
358
|
prevIndexByKey.delete(k);
|
|
359
|
+
cleanupByKey.delete(k);
|
|
334
360
|
}
|
|
335
361
|
}
|
|
336
362
|
}
|
|
@@ -355,10 +381,25 @@ export function repeat(container, store, arrayKey, options) {
|
|
|
355
381
|
updateList();
|
|
356
382
|
logWarn('[Lume.js] repeat(): store is not reactive (no $subscribe or subscribe method)');
|
|
357
383
|
return () => {
|
|
384
|
+
for (const [k, el] of elementsByKey) {
|
|
385
|
+
const prevItem = prevItemsByKey.get(k);
|
|
386
|
+
const cleanup = cleanupByKey.get(k);
|
|
387
|
+
if (typeof cleanup === 'function') {
|
|
388
|
+
try {
|
|
389
|
+
cleanup();
|
|
390
|
+
} catch (err) {
|
|
391
|
+
logError(`[Lume.js] repeat(): cleanup error for key "${k}":`, err);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
if (typeof remove === 'function') {
|
|
395
|
+
remove(prevItem, el);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
358
398
|
containerEl.replaceChildren();
|
|
359
399
|
elementsByKey.clear();
|
|
360
400
|
prevItemsByKey.clear();
|
|
361
401
|
prevIndexByKey.clear();
|
|
402
|
+
cleanupByKey.clear();
|
|
362
403
|
seenKeys.clear();
|
|
363
404
|
};
|
|
364
405
|
}
|
|
@@ -367,11 +408,27 @@ export function repeat(container, store, arrayKey, options) {
|
|
|
367
408
|
if (typeof unsubscribe === 'function') {
|
|
368
409
|
unsubscribe();
|
|
369
410
|
}
|
|
411
|
+
// Invoke cleanup and remove callback for all remaining elements before clearing
|
|
412
|
+
for (const [k, el] of elementsByKey) {
|
|
413
|
+
const prevItem = prevItemsByKey.get(k);
|
|
414
|
+
const cleanup = cleanupByKey.get(k);
|
|
415
|
+
if (typeof cleanup === 'function') {
|
|
416
|
+
try {
|
|
417
|
+
cleanup();
|
|
418
|
+
} catch (err) {
|
|
419
|
+
logError(`[Lume.js] repeat(): cleanup error for key "${k}":`, err);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
if (typeof remove === 'function') {
|
|
423
|
+
remove(prevItem, el);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
370
426
|
// Clear DOM elements (replaceChildren is faster than loop)
|
|
371
427
|
containerEl.replaceChildren();
|
|
372
428
|
elementsByKey.clear();
|
|
373
429
|
prevItemsByKey.clear();
|
|
374
430
|
prevIndexByKey.clear();
|
|
431
|
+
cleanupByKey.clear();
|
|
375
432
|
seenKeys.clear();
|
|
376
433
|
};
|
|
377
434
|
}
|
package/src/core/state.js
CHANGED
|
@@ -169,6 +169,27 @@ export function state(obj) {
|
|
|
169
169
|
const REACTIVE_BRAND = Symbol('lume.reactive');
|
|
170
170
|
obj[REACTIVE_BRAND] = true;
|
|
171
171
|
|
|
172
|
+
// Defined once per state instance — not per property read — to avoid per-read closure allocation.
|
|
173
|
+
const registerEffect = (key, executeFn) => {
|
|
174
|
+
if (!listeners[key]) listeners[key] = [];
|
|
175
|
+
|
|
176
|
+
const callback = () => {
|
|
177
|
+
pendingEffects.add(executeFn);
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
listeners[key].push(callback);
|
|
181
|
+
|
|
182
|
+
return () => {
|
|
183
|
+
if (listeners[key]) {
|
|
184
|
+
const idx = listeners[key].indexOf(callback);
|
|
185
|
+
if (idx !== -1) {
|
|
186
|
+
listeners[key].splice(idx, 1);
|
|
187
|
+
if (listeners[key].length === 0) delete listeners[key];
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
};
|
|
192
|
+
|
|
172
193
|
const proxy = new Proxy(obj, {
|
|
173
194
|
get(target, key) {
|
|
174
195
|
// Skip effect tracking for internal meta methods (e.g. $subscribe)
|
|
@@ -180,29 +201,6 @@ export function state(obj) {
|
|
|
180
201
|
|
|
181
202
|
// Notify active read observers (effects, devtools, etc.)
|
|
182
203
|
if (readers.size > 0) {
|
|
183
|
-
const registerEffect = (key, executeFn) => {
|
|
184
|
-
if (!listeners[key]) listeners[key] = [];
|
|
185
|
-
|
|
186
|
-
const callback = () => {
|
|
187
|
-
// Queue effect in this state's pending set
|
|
188
|
-
// Set deduplicates - effect runs once even if multiple keys change
|
|
189
|
-
pendingEffects.add(executeFn);
|
|
190
|
-
};
|
|
191
|
-
|
|
192
|
-
listeners[key].push(callback);
|
|
193
|
-
|
|
194
|
-
// Return unsubscribe function (no initial call for effects)
|
|
195
|
-
return () => {
|
|
196
|
-
if (listeners[key]) {
|
|
197
|
-
const idx = listeners[key].indexOf(callback);
|
|
198
|
-
if (idx !== -1) {
|
|
199
|
-
listeners[key].splice(idx, 1);
|
|
200
|
-
if (listeners[key].length === 0) delete listeners[key];
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
};
|
|
204
|
-
};
|
|
205
|
-
|
|
206
204
|
for (const reader of readers) {
|
|
207
205
|
reader(proxy, key, registerEffect);
|
|
208
206
|
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"shared-nXhT2Lh7.mjs","sources":["../src/utils/log.js","../src/core/state.js","../src/core/effect.js"],"sourcesContent":["/**\n * Environment-safe logging utilities for constrained runtimes\n * (e.g. service workers, embedded engines, SSR environments).\n *\n * All core and addon files should import these instead of\n * calling console.* directly to avoid ReferenceError when\n * console is not defined.\n */\n\nexport function logWarn(msg, ...rest) {\n if (typeof console !== 'undefined' && typeof console.warn === 'function') {\n console.warn(msg, ...rest);\n }\n}\n\nexport function logError(msg, ...rest) {\n if (typeof console !== 'undefined' && typeof console.error === 'function') {\n console.error(msg, ...rest);\n }\n}\n","/**\n * Lume-JS Reactive State Core\n *\n * Provides minimal reactive state with standard JavaScript.\n * Features automatic microtask batching for performance.\n * Read tracking is opt-in via withReadObserver — state.js has zero permanent\n * dependency on effect.js or any other module.\n *\n * Features:\n * - Lightweight and Go-style\n * - Explicit nested states\n * - $subscribe for listening to key changes\n * - Cleanup with unsubscribe\n * - Per-state microtask batching for writes\n * - Scope-based read tracking via withReadObserver (multi-observer safe)\n *\n * Usage:\n * import { state } from \"lume-js\";\n *\n * const store = state({ count: 0 });\n * const unsub = store.$subscribe(\"count\", val => console.log(val));\n * unsub(); // cleanup\n */\n\nimport { logError } from '../utils/log.js';\n\n// Per-state batching – each state object maintains its own microtask flush.\n// This keeps effects simple and aligned with Lume's minimal philosophy.\n\n/**\n * Creates a reactive state object.\n *\n * @param {Object} obj - Initial state object (must be plain object)\n * @returns {Proxy} Reactive proxy with $subscribe method\n *\n * @example\n * const store = state({ count: 0 });\n */\n\n// Active read observers — only populated during withReadObserver scopes.\n// This keeps state.js pure: tracking only happens when someone explicitly\n// asks to observe reads within a synchronous function call.\n//\n// Note: This Set is module-level, so all reactive state instances and effects\n// within the SAME module instance share it. This is standard behavior for\n// auto-tracking reactive libraries (Vue, MobX, Solid, etc.). Multiple copies\n// of the lume-js module (e.g. from different bundled chunks) each get their\n// own independent Set via ES module / CommonJS isolation.\nconst readers = new Set();\n\n/**\n * Run a function with a read observer active.\n * The observer receives (proxy, key, registerEffect) for every property read.\n * Multiple observers can be active simultaneously (nested effects, devtools, etc.)\n *\n * Internal API — used by effect.js for auto-tracking. May be stabilized\n * for third-party addons in a future release.\n * @param {function} onRead - Called on each property access inside fn\n * @param {function} fn - The function to run under observation\n */\nexport function withReadObserver(onRead, fn) {\n readers.add(onRead);\n try {\n return fn();\n } finally {\n readers.delete(onRead);\n }\n}\n\nexport function state(obj) {\n // Validate input\n if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {\n throw new Error('state() requires a plain object');\n }\n if (Object.isFrozen(obj) || Object.isSealed(obj)) {\n throw new Error('state() requires a mutable plain object');\n }\n\n // Object.create(null) - no prototype chain lookups\n const listeners = Object.create(null);\n const pendingNotifications = new Map(); // Per-state pending changes\n const pendingEffects = new Set(); // Dedupe effects per state\n const beforeFlushHooks = [];\n let flushScheduled = false;\n\n /**\n * Schedule a single microtask flush for this state object.\n *\n * Flush order per state:\n * 1) Notify subscribers for changed keys (key → subscribers)\n * 2) Run each queued effect exactly once (Set-based dedupe)\n * 3) Repeat up to 100 iterations to handle cascading updates,\n * then log an error to prevent infinite loops.\n *\n * Notes:\n * - Batching is per state; effects that depend on multiple states\n * may run once per state that changed (by design).\n */\n function scheduleFlush() {\n if (flushScheduled) return;\n\n flushScheduled = true;\n queueMicrotask(() => {\n let iterations = 0;\n const MAX_ITERATIONS = 100;\n\n try {\n while ((pendingNotifications.size > 0 || pendingEffects.size > 0) && iterations < MAX_ITERATIONS) {\n iterations++;\n\n // Run registered before-flush hooks (e.g. plugin onNotify)\n for (let i = 0; i < beforeFlushHooks.length; i++) {\n try {\n beforeFlushHooks[i]();\n } catch (err) {\n logError('[Lume.js state] Error in beforeFlush hook:', err);\n }\n }\n\n // Notify all subscribers of changed keys\n for (const [key, value] of pendingNotifications) {\n if (listeners[key]) {\n const subs = listeners[key];\n let i = 0;\n while (i < subs.length) {\n const fn = subs[i];\n try {\n fn(value);\n } catch (err) {\n logError(`[Lume.js state] Error notifying subscriber for key \"${String(key)}\":`, err);\n }\n // Only advance if fn wasn't removed (something shifted into its place)\n if (subs[i] === fn) i++;\n }\n }\n }\n\n pendingNotifications.clear();\n\n // Run each effect exactly once (Set deduplicates)\n const effects = new Array(pendingEffects.size);\n let idx = 0;\n for (const effect of pendingEffects) {\n effects[idx++] = effect;\n }\n pendingEffects.clear();\n for (let i = 0; i < effects.length; i++) {\n try {\n effects[i]();\n } catch (err) {\n logError('[Lume.js state] Error in effect:', err);\n }\n }\n }\n } finally {\n flushScheduled = false;\n }\n\n if (iterations >= MAX_ITERATIONS) {\n logError(\n '[Lume.js state] Maximum flush iterations reached (100). ' +\n 'This usually indicates an infinite loop caused by an effect or computed mutating state it depends on.'\n );\n }\n });\n }\n\n // Brand symbol for type-level reactive identification\n const REACTIVE_BRAND = Symbol('lume.reactive');\n obj[REACTIVE_BRAND] = true;\n\n const proxy = new Proxy(obj, {\n get(target, key) {\n // Skip effect tracking for internal meta methods (e.g. $subscribe)\n if (typeof key === 'string' && key.startsWith('$')) {\n return target[key];\n }\n\n const value = target[key];\n\n // Notify active read observers (effects, devtools, etc.)\n if (readers.size > 0) {\n const registerEffect = (key, executeFn) => {\n if (!listeners[key]) listeners[key] = [];\n\n const callback = () => {\n // Queue effect in this state's pending set\n // Set deduplicates - effect runs once even if multiple keys change\n pendingEffects.add(executeFn);\n };\n\n listeners[key].push(callback);\n\n // Return unsubscribe function (no initial call for effects)\n return () => {\n if (listeners[key]) {\n const idx = listeners[key].indexOf(callback);\n if (idx !== -1) {\n listeners[key].splice(idx, 1);\n if (listeners[key].length === 0) delete listeners[key];\n }\n }\n };\n };\n\n for (const reader of readers) {\n reader(proxy, key, registerEffect);\n }\n }\n\n return value;\n },\n\n set(target, key, value) {\n const oldValue = target[key];\n\n // Skip update if value unchanged - Object.is() handles NaN and -0 correctly\n if (Object.is(oldValue, value)) return true;\n\n target[key] = value;\n\n // Batch notifications at the state level (per-state, not global)\n pendingNotifications.set(key, value);\n scheduleFlush();\n\n return true;\n }\n });\n\n /**\n * Subscribe to changes for a specific key.\n * Calls the callback immediately with the current value.\n * Returns an unsubscribe function for cleanup.\n *\n * @param {string} key - Property key to watch\n * @param {function} fn - Callback function\n * @returns {function} Unsubscribe function\n */\n // Set on obj (not proxy) to avoid triggering the set trap.\n // The get trap already returns target[key] directly for $-prefixed keys.\n /**\n * Register a callback to run before each flush.\n * Returns an unsubscribe function.\n */\n obj.$beforeFlush = (fn) => {\n if (typeof fn !== 'function') {\n throw new Error('$beforeFlush requires a function');\n }\n if (beforeFlushHooks.indexOf(fn) === -1) {\n beforeFlushHooks.push(fn);\n }\n return () => {\n const idx = beforeFlushHooks.indexOf(fn);\n if (idx !== -1) {\n beforeFlushHooks.splice(idx, 1);\n }\n };\n };\n\n obj.$subscribe = (key, fn) => {\n if (typeof fn !== 'function') {\n throw new Error('Subscriber must be a function');\n }\n\n if (!listeners[key]) listeners[key] = [];\n listeners[key].push(fn);\n\n // Call immediately with current value (NOT batched)\n fn(proxy[key]);\n\n // Return unsubscribe function\n return () => {\n if (listeners[key]) {\n const idx = listeners[key].indexOf(fn);\n if (idx !== -1) {\n listeners[key].splice(idx, 1);\n if (listeners[key].length === 0) delete listeners[key];\n }\n }\n };\n };\n\n return proxy;\n}\n","import { withReadObserver } from './state.js';\nimport { logError } from '../utils/log.js';\n\n/**\n * Lume-JS Effect\n *\n * Reactive effects with two modes:\n * 1. Auto-tracking (default): Tracks dependencies automatically via withReadObserver\n * 2. Explicit deps: You specify exactly what triggers re-runs\n *\n * Auto-tracking uses scope-based read observation — state.js has zero permanent\n * dependency on this module. Read tracking is only active during the synchronous\n * execution of an effect's body.\n *\n * Usage:\n * import { effect } from \"lume-js\";\n *\n * // Auto-tracking mode (existing behavior)\n * effect(() => {\n * console.log('Count is:', store.count);\n * // Automatically re-runs when store.count changes\n * });\n *\n * // Explicit deps mode (new - no magic)\n * effect(() => {\n * console.log('Count is:', store.count);\n * }, [[store, 'count']]); // Only re-runs when store.count changes\n *\n * Features:\n * - Automatic dependency collection via withReadObserver scope (default)\n * - Explicit dependencies for side-effects\n * - Returns cleanup function\n * - Compatible with per-state batching\n */\n\n// Module-scoped effect context (prevents third-party spoofing via globalThis)\nlet currentEffect = null;\n\n// withReadObserver is used below to scope read tracking to synchronous effect execution.\n\n/**\n * Creates an effect that runs reactively\n *\n * @param {function} fn - Function to run reactively\n * @param {Array<[object, string]>} [deps] - Optional explicit dependencies as [store, key] tuples\n * @returns {function} Cleanup function to stop the effect\n *\n * @example\n * // Auto-tracking (default)\n * const store = state({ count: 0 });\n * effect(() => {\n * document.title = `Count: ${store.count}`;\n * });\n * \n * @example\n * // Explicit deps (no magic)\n * effect(() => {\n * analytics.log(store.count); // Won't track store.count automatically\n * }, [[store, 'count']]); // Explicit: only re-run on store.count\n */\nexport function effect(fn, deps) {\n if (typeof fn !== 'function') {\n throw new Error('effect() requires a function');\n }\n\n const cleanups = [];\n let isRunning = false;\n\n /**\n * Execute the effect function\n */\n const execute = () => {\n /* v8 ignore next -- re-entry guard: unreachable because $subscribe fires via microtask after isRunning resets in finally */\n if (isRunning) return;\n isRunning = true;\n\n try {\n fn();\n } catch (error) {\n logError('[Lume.js effect] Error in effect:', error);\n throw error;\n } finally {\n isRunning = false;\n }\n };\n\n // EXPLICIT DEPS MODE: deps array provided\n if (Array.isArray(deps)) {\n // Subscribe to each [store, key1, key2, ...] tuple explicitly\n for (const dep of deps) {\n if (Array.isArray(dep) && dep.length >= 2) {\n const [store, ...keys] = dep;\n if (store && typeof store.$subscribe === 'function') {\n // Subscribe to each key in this tuple\n for (const key of keys) {\n // $subscribe calls immediately, then on changes\n // We want: call execute immediately once, then on changes\n let isFirst = true;\n const unsub = store.$subscribe(key, () => {\n if (isFirst) {\n isFirst = false;\n return; // Skip first call, we'll run execute() below\n }\n execute();\n });\n cleanups.push(unsub);\n }\n }\n }\n }\n // Run immediately\n execute();\n }\n // AUTO-TRACKING MODE: no deps (existing behavior)\n else {\n const executeWithTracking = () => {\n /* v8 ignore next -- defensive guard: synchronous re-entry is unreachable through the public API */\n if (isRunning) return;\n\n // Save previous subscriptions instead of cleaning immediately.\n // If fn() doesn't read any state (early return / error), we restore\n // them so the effect stays reactive.\n const oldCleanups = cleanups.splice(0);\n\n // Create effect context for tracking\n const myContext = {\n fn,\n cleanups,\n execute: executeWithTracking,\n tracking: {}\n };\n\n // Set as current effect (for state.js to detect)\n // Save previous context to support nested effects/computed\n const previousEffect = currentEffect;\n currentEffect = myContext;\n isRunning = true;\n\n try {\n const onRead = (proxy, key, registerEffect) => {\n // Only the currently active effect (not a nested one) creates subscriptions\n if (currentEffect !== myContext) return;\n if (myContext.tracking[key]) return;\n myContext.tracking[key] = true;\n myContext.cleanups.push(registerEffect(key, myContext.execute));\n };\n withReadObserver(onRead, fn);\n } catch (error) {\n // On error, restore old subscriptions so the effect stays reactive\n cleanups.length = 0;\n cleanups.push(...oldCleanups);\n logError('[Lume.js effect] Error in effect:', error);\n throw error;\n } finally {\n // Restore previous context (not undefined) to support nesting\n currentEffect = previousEffect;\n isRunning = false;\n }\n\n // If fn() created new subscriptions, clean old ones.\n // If it didn't (e.g., early return), keep old subscriptions intact.\n if (cleanups.length > 0) {\n for (const cleanup of oldCleanups) cleanup();\n } else {\n cleanups.push(...oldCleanups);\n }\n };\n\n // Run immediately to collect initial dependencies\n executeWithTracking();\n }\n\n // Return cleanup function\n return () => {\n // while/pop is faster than forEach\n while (cleanups.length) cleanups.pop()();\n };\n}"],"names":["effect","key"],"mappings":"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"}
|