lume-js 2.2.1 → 2.3.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 +70 -10
- package/dist/addons.min.mjs +1 -1
- package/dist/addons.mjs +149 -4
- package/dist/addons.mjs.map +1 -1
- package/dist/handlers.min.mjs +1 -1
- package/dist/handlers.mjs +26 -0
- package/dist/handlers.mjs.map +1 -1
- package/dist/index.min.mjs +1 -1
- package/dist/index.mjs +4 -141
- package/dist/index.mjs.map +1 -1
- package/dist/lume.global.js +1 -1
- package/dist/lume.global.js.map +1 -1
- package/dist/shared-Bk_gndPJ.mjs +232 -0
- package/dist/shared-Bk_gndPJ.mjs.map +1 -0
- package/dist/shared-DNe4ez8V.mjs +249 -0
- package/dist/shared-DNe4ez8V.mjs.map +1 -0
- package/dist/shared-DmpHYKx7.mjs +15 -0
- package/dist/shared-DmpHYKx7.mjs.map +1 -0
- package/dist/state.min.mjs +1 -0
- package/dist/state.mjs +7 -0
- package/dist/state.mjs.map +1 -0
- package/package.json +5 -1
- package/src/addons/index.d.ts +70 -2
- package/src/addons/index.js +13 -2
- package/src/addons/persist.js +152 -0
- package/src/addons/repeat.js +120 -5
- package/src/core/batch.js +139 -0
- package/src/core/bindDom.js +7 -3
- package/src/core/effect.js +34 -5
- package/src/core/state.js +110 -73
- package/src/handlers/index.d.ts +24 -0
- package/src/handlers/index.js +1 -0
- package/src/handlers/on.js +60 -0
- package/src/index.d.ts +40 -0
- package/src/index.js +3 -1
- package/src/state.d.ts +17 -0
- package/src/state.js +25 -0
- package/dist/shared-x2HJmEyO.mjs +0 -260
- package/dist/shared-x2HJmEyO.mjs.map +0 -1
package/src/addons/index.d.ts
CHANGED
|
@@ -163,7 +163,25 @@ export interface RepeatOptions<T> {
|
|
|
163
163
|
*/
|
|
164
164
|
update?: (item: T, element: HTMLElement, index: number, context: UpdateContext) => void;
|
|
165
165
|
|
|
166
|
-
/**
|
|
166
|
+
/**
|
|
167
|
+
* Declarative item structure from a standard <template> element.
|
|
168
|
+
* - true: use the first <template> inside the container
|
|
169
|
+
* - string: CSS selector resolving to a <template>
|
|
170
|
+
* - HTMLTemplateElement: use directly
|
|
171
|
+
*
|
|
172
|
+
* The template must contain exactly one root element. It is cloned per
|
|
173
|
+
* item, and its [data-bind] paths are bound against the ITEM on every
|
|
174
|
+
* update: "name" → item.name, "user.city" → item.user.city,
|
|
175
|
+
* "$item" → the item itself, "$index" → current index.
|
|
176
|
+
* Inputs receive .value/.checked; other elements receive textContent
|
|
177
|
+
* (same semantics as bindDom's data-bind). One-way snapshot bindings.
|
|
178
|
+
*
|
|
179
|
+
* When set: options.element is ignored, options.render is ignored (with
|
|
180
|
+
* a console warning); create/update still run on top of the bindings.
|
|
181
|
+
*/
|
|
182
|
+
template?: true | string | HTMLTemplateElement;
|
|
183
|
+
|
|
184
|
+
/** Element tag name or factory function (default: 'div'; ignored when template is set) */
|
|
167
185
|
element?: string | (() => HTMLElement);
|
|
168
186
|
|
|
169
187
|
/**
|
|
@@ -396,7 +414,8 @@ export const debug: Debug;
|
|
|
396
414
|
|
|
397
415
|
/**
|
|
398
416
|
* Returns true if the value is a Lume reactive proxy created by state().
|
|
399
|
-
*
|
|
417
|
+
* Checks the shared reactive brand symbol first (Symbol.for('lume.reactive')),
|
|
418
|
+
* then falls back to duck-typing ($subscribe) for older stores.
|
|
400
419
|
* This is a type guard that narrows the type to ReactiveState.
|
|
401
420
|
*
|
|
402
421
|
* @param obj - Value to check
|
|
@@ -501,3 +520,52 @@ export function createCleanupGroup(): CleanupGroup;
|
|
|
501
520
|
*/
|
|
502
521
|
export function hydrateState(selector?: string): object;
|
|
503
522
|
|
|
523
|
+
/**
|
|
524
|
+
* Options for persist()
|
|
525
|
+
*/
|
|
526
|
+
export interface PersistOptions {
|
|
527
|
+
/**
|
|
528
|
+
* Keys to persist. Default: all own non-$ keys of the store at call time.
|
|
529
|
+
*/
|
|
530
|
+
keys?: string[];
|
|
531
|
+
/**
|
|
532
|
+
* Storage object (localStorage, sessionStorage, or any Storage-like
|
|
533
|
+
* object with getItem/setItem). Default: localStorage.
|
|
534
|
+
*/
|
|
535
|
+
storage?: Pick<Storage, 'getItem' | 'setItem'> | null;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Keep selected store keys in sync with localStorage/sessionStorage:
|
|
540
|
+
* hydrates them on call, then saves on change.
|
|
541
|
+
*
|
|
542
|
+
* - Hydration assigns stored values through the proxy (subscribers fire).
|
|
543
|
+
* - Saves coalesce to one storage write per microtask and are skipped when
|
|
544
|
+
* the serialized snapshot is unchanged.
|
|
545
|
+
* - Storage failures (quota, corrupted JSON, unserializable values) warn
|
|
546
|
+
* on the console — never throw.
|
|
547
|
+
* - With no storage available (SSR), persistence is disabled with a warning.
|
|
548
|
+
*
|
|
549
|
+
* @param store - Reactive store created with state()
|
|
550
|
+
* @param storageKey - The storage entry name to read/write
|
|
551
|
+
* @param options - Persist options
|
|
552
|
+
* @returns Dispose function — stops watching and saving
|
|
553
|
+
* @throws {Error} If store is not reactive or storageKey is empty
|
|
554
|
+
*
|
|
555
|
+
* @example
|
|
556
|
+
* ```typescript
|
|
557
|
+
* import { state } from 'lume-js';
|
|
558
|
+
* import { persist } from 'lume-js/addons';
|
|
559
|
+
*
|
|
560
|
+
* const store = state({ todos: [], filter: 'all', draft: '' });
|
|
561
|
+
*
|
|
562
|
+
* // Hydrate + auto-save todos/filter; draft stays in-memory only
|
|
563
|
+
* const stop = persist(store, 'my-app', { keys: ['todos', 'filter'] });
|
|
564
|
+
* ```
|
|
565
|
+
*/
|
|
566
|
+
export function persist(
|
|
567
|
+
store: ReactiveState<any>,
|
|
568
|
+
storageKey: string,
|
|
569
|
+
options?: PersistOptions
|
|
570
|
+
): Unsubscribe;
|
|
571
|
+
|
package/src/addons/index.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { REACTIVE_BRAND } from "../core/state.js";
|
|
2
|
+
|
|
1
3
|
export { computed } from "./computed.js";
|
|
2
4
|
export { watch } from "./watch.js";
|
|
3
5
|
export { repeat, defaultFocusPreservation, defaultScrollPreservation } from "./repeat.js";
|
|
@@ -5,13 +7,22 @@ export { createDebugPlugin, debug } from "./debug.js";
|
|
|
5
7
|
export { withPlugins } from "./withPlugins.js";
|
|
6
8
|
export { createCleanupGroup } from "./cleanupGroup.js";
|
|
7
9
|
export { hydrateState } from "./hydrateState.js";
|
|
10
|
+
export { persist } from "./persist.js";
|
|
8
11
|
|
|
9
12
|
/**
|
|
10
13
|
* Returns true if the value is a Lume reactive proxy created by state().
|
|
11
|
-
*
|
|
14
|
+
*
|
|
15
|
+
* Checks the shared reactive brand first (a registry symbol stamped by
|
|
16
|
+
* state(), reliable across module copies), then falls back to duck-typing
|
|
17
|
+
* ($subscribe) for proxies from older lume-js versions whose brand was not
|
|
18
|
+
* shared. The brand check uses the `in` operator, which does not pass
|
|
19
|
+
* through the proxy `get` trap — calling isReactive inside an effect does
|
|
20
|
+
* not create a spurious dependency.
|
|
21
|
+
*
|
|
12
22
|
* @param {any} obj
|
|
13
23
|
* @returns {boolean}
|
|
14
24
|
*/
|
|
15
25
|
export function isReactive(obj) {
|
|
16
|
-
return !!(obj && typeof obj === 'object' &&
|
|
26
|
+
return !!(obj && typeof obj === 'object' &&
|
|
27
|
+
(REACTIVE_BRAND in obj || typeof obj.$subscribe === 'function'));
|
|
17
28
|
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lume-JS Persist Addon
|
|
3
|
+
*
|
|
4
|
+
* Keeps selected store keys in sync with localStorage/sessionStorage (or
|
|
5
|
+
* any Storage-like object): hydrates them on call, then saves on change.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* import { state } from "lume-js";
|
|
9
|
+
* import { persist } from "lume-js/addons";
|
|
10
|
+
*
|
|
11
|
+
* const store = state({ todos: [], filter: 'all', draft: '' });
|
|
12
|
+
*
|
|
13
|
+
* // Hydrate + auto-save todos/filter; draft stays in-memory only
|
|
14
|
+
* const stop = persist(store, 'my-app', { keys: ['todos', 'filter'] });
|
|
15
|
+
*
|
|
16
|
+
* Behavior:
|
|
17
|
+
* - Hydration assigns stored values through the proxy, so subscribers and
|
|
18
|
+
* bindings see them like any other write.
|
|
19
|
+
* - Saves are coalesced to one storage write per microtask, and skipped
|
|
20
|
+
* entirely when the serialized snapshot is unchanged.
|
|
21
|
+
* - Storage failures (quota, unavailable, corrupted JSON, unserializable
|
|
22
|
+
* values) are contained: a console warning, never a throw.
|
|
23
|
+
*
|
|
24
|
+
* @security Storage is same-origin but survives schema changes — hydration
|
|
25
|
+
* only assigns keys you watch (never unknown keys from storage), and the
|
|
26
|
+
* core set trap independently blocks prototype-polluting keys.
|
|
27
|
+
*
|
|
28
|
+
* @module addons/persist
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { logWarn } from '../utils/log.js';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Read and parse the stored JSON blob. Returns a plain object, or null
|
|
35
|
+
* when missing, corrupted, or not an object (warns on read errors).
|
|
36
|
+
*/
|
|
37
|
+
function readStored(storage, storageKey) {
|
|
38
|
+
try {
|
|
39
|
+
const raw = storage.getItem(storageKey);
|
|
40
|
+
if (!raw) return null;
|
|
41
|
+
const data = JSON.parse(raw);
|
|
42
|
+
return data && typeof data === 'object' && !Array.isArray(data) ? data : null;
|
|
43
|
+
} catch {
|
|
44
|
+
logWarn(`[Lume.js] persist(): could not read "${storageKey}" — starting fresh`);
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Serialize the watched subset of the store. May throw (circular refs). */
|
|
50
|
+
function serializeKeys(store, watched) {
|
|
51
|
+
const out = {};
|
|
52
|
+
for (const k of watched) out[k] = store[k];
|
|
53
|
+
return JSON.stringify(out);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Sync store keys with a Storage object.
|
|
58
|
+
*
|
|
59
|
+
* @param {object} store - Reactive store created with state()
|
|
60
|
+
* @param {string} storageKey - The storage entry name to read/write
|
|
61
|
+
* @param {object} [options]
|
|
62
|
+
* @param {string[]} [options.keys] - Keys to persist. Default: all own
|
|
63
|
+
* non-$ keys of the store at call time.
|
|
64
|
+
* @param {Storage} [options.storage] - Storage object. Default: localStorage.
|
|
65
|
+
* Pass sessionStorage for per-tab persistence.
|
|
66
|
+
* @returns {function} Dispose function — stops watching and saving.
|
|
67
|
+
*/
|
|
68
|
+
export function persist(store, storageKey, options = {}) {
|
|
69
|
+
if (!store || typeof store.$subscribe !== 'function') {
|
|
70
|
+
throw new Error('[Lume.js] persist() requires a reactive store from state()');
|
|
71
|
+
}
|
|
72
|
+
if (typeof storageKey !== 'string' || storageKey.length === 0) {
|
|
73
|
+
throw new Error('[Lume.js] persist() requires a non-empty storage key');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const storage = options.storage !== undefined
|
|
77
|
+
? options.storage
|
|
78
|
+
: globalThis.localStorage;
|
|
79
|
+
|
|
80
|
+
if (!storage || typeof storage.getItem !== 'function') {
|
|
81
|
+
logWarn('[Lume.js] persist(): no storage available — persistence disabled');
|
|
82
|
+
return () => {};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const watched = Array.isArray(options.keys) && options.keys.length > 0
|
|
86
|
+
? options.keys.slice()
|
|
87
|
+
: Object.keys(store).filter(k => !k.startsWith('$'));
|
|
88
|
+
|
|
89
|
+
// ── Hydrate ────────────────────────────────────────────────────────────
|
|
90
|
+
// Only watched keys are assigned — stale storage can't inject others.
|
|
91
|
+
const stored = readStored(storage, storageKey);
|
|
92
|
+
if (stored) {
|
|
93
|
+
for (const k of watched) {
|
|
94
|
+
if (Object.prototype.hasOwnProperty.call(stored, k)) {
|
|
95
|
+
store[k] = stored[k];
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Save on change ─────────────────────────────────────────────────────
|
|
101
|
+
// Remember what storage holds (post-hydration) so unchanged flushes —
|
|
102
|
+
// including the hydration echo itself — skip the write.
|
|
103
|
+
let lastWritten = null;
|
|
104
|
+
try {
|
|
105
|
+
lastWritten = serializeKeys(store, watched);
|
|
106
|
+
} catch {
|
|
107
|
+
// Unserializable initial state: first save attempt will warn.
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let scheduled = false;
|
|
111
|
+
let disposed = false;
|
|
112
|
+
|
|
113
|
+
const flushSave = () => {
|
|
114
|
+
scheduled = false;
|
|
115
|
+
if (disposed) return;
|
|
116
|
+
|
|
117
|
+
let json;
|
|
118
|
+
try {
|
|
119
|
+
json = serializeKeys(store, watched);
|
|
120
|
+
} catch (err) {
|
|
121
|
+
logWarn('[Lume.js] persist(): state not serializable — skipping save', err);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (json === lastWritten) return;
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
storage.setItem(storageKey, json);
|
|
128
|
+
lastWritten = json;
|
|
129
|
+
} catch (err) {
|
|
130
|
+
logWarn('[Lume.js] persist(): could not write — storage full or unavailable?', err);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const save = () => {
|
|
135
|
+
if (scheduled) return;
|
|
136
|
+
scheduled = true;
|
|
137
|
+
queueMicrotask(flushSave);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const unsubs = watched.map(k => {
|
|
141
|
+
let first = true;
|
|
142
|
+
return store.$subscribe(k, () => {
|
|
143
|
+
if (first) { first = false; return; } // skip $subscribe's immediate call
|
|
144
|
+
save();
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
return () => {
|
|
149
|
+
disposed = true;
|
|
150
|
+
while (unsubs.length) unsubs.pop()();
|
|
151
|
+
};
|
|
152
|
+
}
|
package/src/addons/repeat.js
CHANGED
|
@@ -22,6 +22,38 @@
|
|
|
22
22
|
* store.items = [...items] // ✅ Triggers update
|
|
23
23
|
*
|
|
24
24
|
* ═══════════════════════════════════════════════════════════════════════
|
|
25
|
+
* PATTERN 0: Template-based (recommended) — declarative, zero DOM code
|
|
26
|
+
* ═══════════════════════════════════════════════════════════════════════
|
|
27
|
+
*
|
|
28
|
+
* HTML — a standard <template> element, valid HTML, no custom syntax:
|
|
29
|
+
*
|
|
30
|
+
* <ul id="list">
|
|
31
|
+
* <template>
|
|
32
|
+
* <li>
|
|
33
|
+
* <strong data-bind="name"></strong>
|
|
34
|
+
* <span data-bind="role"></span>
|
|
35
|
+
* <em data-bind="$index"></em>
|
|
36
|
+
* </li>
|
|
37
|
+
* </template>
|
|
38
|
+
* </ul>
|
|
39
|
+
*
|
|
40
|
+
* JS:
|
|
41
|
+
*
|
|
42
|
+
* repeat('#list', store, 'people', {
|
|
43
|
+
* key: p => p.id,
|
|
44
|
+
* template: true // use the <template> inside the container
|
|
45
|
+
* });
|
|
46
|
+
*
|
|
47
|
+
* data-bind paths resolve against EACH ITEM: "name" → item.name,
|
|
48
|
+
* "user.city" → item.user.city, "$item" → the item itself (primitive
|
|
49
|
+
* arrays), "$index" → current index. Inputs get .value/.checked, other
|
|
50
|
+
* elements get textContent — identical semantics to bindDom's data-bind.
|
|
51
|
+
* These are one-way snapshot bindings re-applied per list update (items
|
|
52
|
+
* are plain objects, not stores). Combine with create/update for event
|
|
53
|
+
* listeners or extra binding. template also accepts a CSS selector or an
|
|
54
|
+
* HTMLTemplateElement.
|
|
55
|
+
*
|
|
56
|
+
* ═══════════════════════════════════════════════════════════════════════
|
|
25
57
|
* PATTERN 1: Simple (render only) - for simple cases or backward compat
|
|
26
58
|
* ═══════════════════════════════════════════════════════════════════════
|
|
27
59
|
*
|
|
@@ -79,6 +111,64 @@
|
|
|
79
111
|
* });
|
|
80
112
|
*/
|
|
81
113
|
import { logWarn, logError } from '../utils/log.js';
|
|
114
|
+
import { applyBindValue } from '../core/bindDom.js';
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Resolve the template option to its single root element.
|
|
118
|
+
* Accepts true (first <template> inside the container), a CSS selector,
|
|
119
|
+
* or an HTMLTemplateElement directly.
|
|
120
|
+
*/
|
|
121
|
+
function resolveTemplateRoot(template, containerEl) {
|
|
122
|
+
let templateEl = template;
|
|
123
|
+
if (template === true) {
|
|
124
|
+
templateEl = containerEl.querySelector('template');
|
|
125
|
+
} else if (typeof template === 'string') {
|
|
126
|
+
templateEl = document.querySelector(template);
|
|
127
|
+
}
|
|
128
|
+
if (!templateEl || templateEl.tagName !== 'TEMPLATE') {
|
|
129
|
+
throw new Error('[Lume.js] repeat(): template not found or not a <template> element');
|
|
130
|
+
}
|
|
131
|
+
if (templateEl.content.children.length !== 1) {
|
|
132
|
+
throw new Error('[Lume.js] repeat(): template must contain exactly one root element');
|
|
133
|
+
}
|
|
134
|
+
return templateEl.content.firstElementChild;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Collect [data-bind] nodes of a cloned item element into a compiled
|
|
139
|
+
* binding list. Paths are resolved against the ITEM (not the store):
|
|
140
|
+
* data-bind="name" → item.name
|
|
141
|
+
* data-bind="user.city" → item.user.city
|
|
142
|
+
* data-bind="$item" → the item itself (for primitive arrays)
|
|
143
|
+
* data-bind="$index" → the item's current index
|
|
144
|
+
*/
|
|
145
|
+
function collectItemBindings(el) {
|
|
146
|
+
const bindings = [];
|
|
147
|
+
const add = (node) => {
|
|
148
|
+
const path = node.getAttribute('data-bind');
|
|
149
|
+
bindings.push({ node, path, keys: path === '$item' || path === '$index' ? null : path.split('.') });
|
|
150
|
+
};
|
|
151
|
+
if (el.hasAttribute('data-bind')) add(el);
|
|
152
|
+
for (const node of el.querySelectorAll('[data-bind]')) add(node);
|
|
153
|
+
return bindings;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function applyItemBindings(bindings, item, index) {
|
|
157
|
+
for (const b of bindings) {
|
|
158
|
+
let val;
|
|
159
|
+
if (b.path === '$index') {
|
|
160
|
+
val = index;
|
|
161
|
+
} else if (b.path === '$item') {
|
|
162
|
+
val = item;
|
|
163
|
+
} else {
|
|
164
|
+
val = item;
|
|
165
|
+
for (let i = 0; i < b.keys.length && val != null; i++) {
|
|
166
|
+
val = val[b.keys[i]];
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
applyBindValue(b.node, val);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
82
172
|
|
|
83
173
|
/**
|
|
84
174
|
* Default focus preservation strategy
|
|
@@ -173,7 +263,8 @@ export function defaultScrollPreservation(container, context = {}) {
|
|
|
173
263
|
* @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).
|
|
174
264
|
* @param {Function} [options.update] - Function for data binding: (item, element, index, { isFirstRender }) => void. Skipped if same item reference AND same index.
|
|
175
265
|
* @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.
|
|
176
|
-
* @param {string|
|
|
266
|
+
* @param {true|string|HTMLTemplateElement} [options.template] - Declarative item structure from a <template> element: true = first <template> inside the container, string = CSS selector, or the element itself. The template must have exactly one root element; it is cloned per item and its [data-bind] paths are bound to the item on every update ("name", "user.city", "$item", "$index"). When set, options.element is ignored and options.render is ignored (with a warning); create/update remain available on top.
|
|
267
|
+
* @param {string|Function} [options.element='div'] - Element tag name or factory function (ignored when options.template is set)
|
|
177
268
|
* @param {Function|null} [options.preserveFocus=defaultFocusPreservation] - Focus preservation strategy (null to disable)
|
|
178
269
|
* @param {Function|null} [options.preserveScroll=defaultScrollPreservation] - Scroll preservation strategy (null to disable)
|
|
179
270
|
* @returns {Function} Cleanup function
|
|
@@ -186,6 +277,7 @@ export function repeat(container, store, arrayKey, options) {
|
|
|
186
277
|
create,
|
|
187
278
|
update,
|
|
188
279
|
remove,
|
|
280
|
+
template = null,
|
|
189
281
|
element = 'div',
|
|
190
282
|
preserveFocus = defaultFocusPreservation,
|
|
191
283
|
preserveScroll = defaultScrollPreservation
|
|
@@ -206,7 +298,15 @@ export function repeat(container, store, arrayKey, options) {
|
|
|
206
298
|
throw new Error('[Lume.js] repeat(): options.key must be a function');
|
|
207
299
|
}
|
|
208
300
|
|
|
209
|
-
|
|
301
|
+
// Template mode: structure and data binding come from the <template>;
|
|
302
|
+
// render/create/update are all optional on top of it.
|
|
303
|
+
const templateRoot = template ? resolveTemplateRoot(template, containerEl) : null;
|
|
304
|
+
|
|
305
|
+
if (templateRoot && typeof render === 'function') {
|
|
306
|
+
logWarn('[Lume.js] repeat(): options.render is ignored when options.template is set — use create/update instead');
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (!templateRoot && typeof render !== 'function' && typeof create !== 'function') {
|
|
210
310
|
throw new Error('[Lume.js] repeat(): options.render or options.create must be a function');
|
|
211
311
|
}
|
|
212
312
|
|
|
@@ -218,9 +318,12 @@ export function repeat(container, store, arrayKey, options) {
|
|
|
218
318
|
const prevIndexByKey = new Map();
|
|
219
319
|
// key -> cleanup function returned by create()
|
|
220
320
|
const cleanupByKey = new Map();
|
|
321
|
+
// key -> compiled [data-bind] nodes of the clone (template mode only)
|
|
322
|
+
const bindingsByKey = new Map();
|
|
221
323
|
const seenKeys = new Set();
|
|
222
324
|
|
|
223
325
|
function createElement() {
|
|
326
|
+
if (templateRoot) return templateRoot.cloneNode(true);
|
|
224
327
|
return typeof element === 'function'
|
|
225
328
|
? element()
|
|
226
329
|
: document.createElement(element);
|
|
@@ -298,10 +401,13 @@ export function repeat(container, store, arrayKey, options) {
|
|
|
298
401
|
if (isFirstRender) {
|
|
299
402
|
el = createElement();
|
|
300
403
|
elementsByKey.set(k, el);
|
|
404
|
+
if (templateRoot) {
|
|
405
|
+
bindingsByKey.set(k, collectItemBindings(el));
|
|
406
|
+
}
|
|
301
407
|
}
|
|
302
408
|
|
|
303
409
|
try {
|
|
304
|
-
// Call create for new elements (DOM structure)
|
|
410
|
+
// Call create for new elements (DOM structure / event listeners)
|
|
305
411
|
if (isFirstRender && create) {
|
|
306
412
|
const cleanup = create(item, el, i);
|
|
307
413
|
if (typeof cleanup === 'function') {
|
|
@@ -309,11 +415,17 @@ export function repeat(container, store, arrayKey, options) {
|
|
|
309
415
|
}
|
|
310
416
|
}
|
|
311
417
|
|
|
312
|
-
//
|
|
418
|
+
// Data binding (new and existing elements)
|
|
313
419
|
// Skip if same item reference AND same index (optimization)
|
|
314
420
|
const prevItem = prevItemsByKey.get(k);
|
|
315
421
|
const prevIndex = prevIndexByKey.get(k);
|
|
316
|
-
if (
|
|
422
|
+
if (templateRoot) {
|
|
423
|
+
if (prevItem !== item || prevIndex !== i) {
|
|
424
|
+
applyItemBindings(bindingsByKey.get(k), item, i);
|
|
425
|
+
// update is optional extra binding on top of the template
|
|
426
|
+
if (update) update(item, el, i, { isFirstRender });
|
|
427
|
+
}
|
|
428
|
+
} else if (update) {
|
|
317
429
|
if (prevItem !== item || prevIndex !== i) {
|
|
318
430
|
update(item, el, i, { isFirstRender });
|
|
319
431
|
}
|
|
@@ -359,6 +471,7 @@ export function repeat(container, store, arrayKey, options) {
|
|
|
359
471
|
prevItemsByKey.delete(k);
|
|
360
472
|
prevIndexByKey.delete(k);
|
|
361
473
|
cleanupByKey.delete(k);
|
|
474
|
+
bindingsByKey.delete(k);
|
|
362
475
|
}
|
|
363
476
|
}
|
|
364
477
|
}
|
|
@@ -402,6 +515,7 @@ export function repeat(container, store, arrayKey, options) {
|
|
|
402
515
|
prevItemsByKey.clear();
|
|
403
516
|
prevIndexByKey.clear();
|
|
404
517
|
cleanupByKey.clear();
|
|
518
|
+
bindingsByKey.clear();
|
|
405
519
|
seenKeys.clear();
|
|
406
520
|
};
|
|
407
521
|
}
|
|
@@ -431,6 +545,7 @@ export function repeat(container, store, arrayKey, options) {
|
|
|
431
545
|
prevItemsByKey.clear();
|
|
432
546
|
prevIndexByKey.clear();
|
|
433
547
|
cleanupByKey.clear();
|
|
548
|
+
bindingsByKey.clear();
|
|
434
549
|
seenKeys.clear();
|
|
435
550
|
};
|
|
436
551
|
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lume-JS Cross-Store Batching
|
|
3
|
+
*
|
|
4
|
+
* While batchDepth > 0, states skip their microtask flush and enqueue a
|
|
5
|
+
* small flush handle here instead (via enqueueIfBatching, called from
|
|
6
|
+
* state.js's scheduler); batch() drains the set synchronously when the
|
|
7
|
+
* outermost batch ends. Effects collected from all enqueued states run
|
|
8
|
+
* from one Set per wave, so an effect depending on several mutated stores
|
|
9
|
+
* runs exactly once per batch instead of once per store.
|
|
10
|
+
*
|
|
11
|
+
* This module never imports state.js — state.js imports from here — so
|
|
12
|
+
* there is no cycle, no global scheduler object, and no import side effect.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { logError, logWarn } from '../utils/log.js';
|
|
16
|
+
|
|
17
|
+
// Cap for cascading flush waves (effects mutating state that re-triggers
|
|
18
|
+
// effects). Shared with the per-state microtask flush in state.js.
|
|
19
|
+
export const MAX_FLUSH_ITERATIONS = 100;
|
|
20
|
+
|
|
21
|
+
let batchDepth = 0;
|
|
22
|
+
const batchedStates = new Set();
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Called by state.js when a write is scheduled. Returns true if a batch is
|
|
26
|
+
* active and the state's flush handle was captured (the caller must then
|
|
27
|
+
* skip its own microtask scheduling).
|
|
28
|
+
*
|
|
29
|
+
* Internal API between core modules — not exported from the package root.
|
|
30
|
+
*
|
|
31
|
+
* @param {{runBeforeFlushHooks: function, notifySubscribers: function, takeEffects: function}} handle
|
|
32
|
+
* @returns {boolean}
|
|
33
|
+
*/
|
|
34
|
+
export function enqueueIfBatching(handle) {
|
|
35
|
+
if (batchDepth === 0) return false;
|
|
36
|
+
batchedStates.add(handle);
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function flushBatchedStates() {
|
|
41
|
+
let iterations = 0;
|
|
42
|
+
while (batchedStates.size > 0 && iterations < MAX_FLUSH_ITERATIONS) {
|
|
43
|
+
iterations++;
|
|
44
|
+
const wave = Array.from(batchedStates);
|
|
45
|
+
batchedStates.clear();
|
|
46
|
+
|
|
47
|
+
// Notify each state's subscribers, collecting effects into one
|
|
48
|
+
// deduplicated set (the same effect queued by N stores runs once).
|
|
49
|
+
const effects = new Set();
|
|
50
|
+
for (const s of wave) {
|
|
51
|
+
s.runBeforeFlushHooks();
|
|
52
|
+
s.notifySubscribers();
|
|
53
|
+
for (const fx of s.takeEffects()) effects.add(fx);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Effects run after all subscribers of the wave. Writes they make
|
|
57
|
+
// re-enter batchedStates (depth is still held) → next iteration.
|
|
58
|
+
for (const fx of effects) {
|
|
59
|
+
try { fx(); }
|
|
60
|
+
catch (err) { logError('[Lume.js state] Error in effect:', err); }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (iterations >= MAX_FLUSH_ITERATIONS) {
|
|
64
|
+
// Drop the runaway wave so a future, unrelated batch doesn't inherit
|
|
65
|
+
// it. Nothing is lost permanently: the states keep their queued work
|
|
66
|
+
// and flush it on their next write via the normal microtask path.
|
|
67
|
+
batchedStates.clear();
|
|
68
|
+
logError(
|
|
69
|
+
'[Lume.js state] Maximum batch flush iterations reached (100). ' +
|
|
70
|
+
'This usually indicates an infinite loop caused by an effect or computed mutating state it depends on.'
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Group multiple state writes and flush them together, synchronously,
|
|
77
|
+
* when the outermost batch() returns.
|
|
78
|
+
*
|
|
79
|
+
* Guarantees:
|
|
80
|
+
* - Subscribers see only the final value of each key (intermediate writes
|
|
81
|
+
* within the batch are coalesced, as with microtask batching).
|
|
82
|
+
* - An effect that depends on several stores mutated in the batch runs
|
|
83
|
+
* exactly ONCE — unlike microtask batching, which is per-state and runs
|
|
84
|
+
* such effects once per store.
|
|
85
|
+
* - Nested batch() calls are absorbed: everything flushes when the
|
|
86
|
+
* outermost batch ends.
|
|
87
|
+
* - If fn throws, writes made before the throw still flush, then the
|
|
88
|
+
* error propagates. State scheduling is left clean either way.
|
|
89
|
+
*
|
|
90
|
+
* `fn` must be synchronous — writes after an `await` happen outside the
|
|
91
|
+
* batch and fall back to normal per-state microtask flushing (a console
|
|
92
|
+
* warning is logged if fn returns a Promise).
|
|
93
|
+
*
|
|
94
|
+
* @param {function} fn - Function performing state writes
|
|
95
|
+
* @returns {*} The return value of fn
|
|
96
|
+
*
|
|
97
|
+
* @example
|
|
98
|
+
* import { state, effect, batch } from 'lume-js';
|
|
99
|
+
*
|
|
100
|
+
* const a = state({ value: 1 });
|
|
101
|
+
* const b = state({ value: 2 });
|
|
102
|
+
* effect(() => render(a.value + b.value));
|
|
103
|
+
*
|
|
104
|
+
* batch(() => {
|
|
105
|
+
* a.value = 10;
|
|
106
|
+
* b.value = 20;
|
|
107
|
+
* }); // render() ran exactly once, seeing 30
|
|
108
|
+
*/
|
|
109
|
+
export function batch(fn) {
|
|
110
|
+
if (typeof fn !== 'function') {
|
|
111
|
+
throw new Error('batch() requires a function');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Nested batch: let the outermost batch flush everything
|
|
115
|
+
if (batchDepth > 0) return fn();
|
|
116
|
+
|
|
117
|
+
batchDepth++;
|
|
118
|
+
let result;
|
|
119
|
+
try {
|
|
120
|
+
result = fn();
|
|
121
|
+
if (result && typeof result.then === 'function') {
|
|
122
|
+
logWarn(
|
|
123
|
+
'[Lume.js batch] batch() received an async function. Only writes before the first await are batched; ' +
|
|
124
|
+
'later writes flush via normal microtasks.'
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
return result;
|
|
128
|
+
} finally {
|
|
129
|
+
// Flush while depth is still held so cascading writes from
|
|
130
|
+
// subscribers/effects keep collecting into batchedStates (deduped),
|
|
131
|
+
// then release. Runs on success AND when fn throws (writes made
|
|
132
|
+
// before the throw are committed, then the error propagates).
|
|
133
|
+
try {
|
|
134
|
+
flushBatchedStates();
|
|
135
|
+
} finally {
|
|
136
|
+
batchDepth--;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
package/src/core/bindDom.js
CHANGED
|
@@ -146,7 +146,7 @@ function handleDataBind(el, store, path, bindingMap) {
|
|
|
146
146
|
if (!result) return null;
|
|
147
147
|
|
|
148
148
|
const { target, key } = result;
|
|
149
|
-
const unsub = target.$subscribe(key, val =>
|
|
149
|
+
const unsub = target.$subscribe(key, val => applyBindValue(el, val));
|
|
150
150
|
|
|
151
151
|
if (isFormInput(el)) {
|
|
152
152
|
bindingMap.set(el, { target, key });
|
|
@@ -205,9 +205,13 @@ function resolveProp(store, path) {
|
|
|
205
205
|
}
|
|
206
206
|
|
|
207
207
|
/**
|
|
208
|
-
* Update element with value (for data-bind)
|
|
208
|
+
* Update element with value (for data-bind).
|
|
209
|
+
*
|
|
210
|
+
* Exported for internal reuse (e.g. the repeat addon's template bindings)
|
|
211
|
+
* so addon and core data-bind semantics never drift. Not part of the
|
|
212
|
+
* public package API.
|
|
209
213
|
*/
|
|
210
|
-
function
|
|
214
|
+
export function applyBindValue(el, val) {
|
|
211
215
|
if (el.tagName === "INPUT") {
|
|
212
216
|
if (el.type === "checkbox") el.checked = Boolean(val);
|
|
213
217
|
else if (el.type === "radio") el.checked = el.value === String(val);
|