lume-js 2.2.0 → 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.
Files changed (43) hide show
  1. package/README.md +90 -19
  2. package/dist/addons.min.mjs +1 -1
  3. package/dist/addons.mjs +160 -6
  4. package/dist/addons.mjs.map +1 -1
  5. package/dist/handlers.min.mjs +1 -1
  6. package/dist/handlers.mjs +38 -2
  7. package/dist/handlers.mjs.map +1 -1
  8. package/dist/index.min.mjs +1 -1
  9. package/dist/index.mjs +4 -141
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/lume.global.js +1 -1
  12. package/dist/lume.global.js.map +1 -1
  13. package/dist/shared-Bk_gndPJ.mjs +232 -0
  14. package/dist/shared-Bk_gndPJ.mjs.map +1 -0
  15. package/dist/shared-DNe4ez8V.mjs +249 -0
  16. package/dist/shared-DNe4ez8V.mjs.map +1 -0
  17. package/dist/shared-DmpHYKx7.mjs +15 -0
  18. package/dist/shared-DmpHYKx7.mjs.map +1 -0
  19. package/dist/state.min.mjs +1 -0
  20. package/dist/state.mjs +7 -0
  21. package/dist/state.mjs.map +1 -0
  22. package/package.json +5 -1
  23. package/src/addons/computed.js +5 -0
  24. package/src/addons/hydrateState.js +32 -4
  25. package/src/addons/index.d.ts +70 -2
  26. package/src/addons/index.js +13 -2
  27. package/src/addons/persist.js +152 -0
  28. package/src/addons/repeat.js +120 -5
  29. package/src/addons/withPlugins.js +7 -1
  30. package/src/core/batch.js +139 -0
  31. package/src/core/bindDom.js +12 -3
  32. package/src/core/effect.js +34 -5
  33. package/src/core/state.js +124 -68
  34. package/src/handlers/index.d.ts +24 -0
  35. package/src/handlers/index.js +1 -0
  36. package/src/handlers/on.js +60 -0
  37. package/src/handlers/stringAttr.js +14 -2
  38. package/src/index.d.ts +40 -0
  39. package/src/index.js +3 -1
  40. package/src/state.d.ts +17 -0
  41. package/src/state.js +25 -0
  42. package/dist/shared-Dcokqj5a.mjs +0 -249
  43. package/dist/shared-Dcokqj5a.mjs.map +0 -1
@@ -2,8 +2,16 @@
2
2
  * Reads initial state from a `<script type="application/json">` element
3
3
  * embedded in the server-rendered HTML. Useful for SSR / hydration patterns.
4
4
  *
5
+ * @security Hydration trusts the DOM. An attacker who can inject HTML before
6
+ * the legitimate script (DOM clobbering) can control the parsed data. The
7
+ * element must be a real `<script type="application/json">` tag; non-script
8
+ * elements are rejected. Use the optional `validate` parameter to enforce a
9
+ * schema (e.g., whitelist allowed keys) before passing to `state()`.
10
+ *
5
11
  * @param {string} [selector='#__LUME_DATA__'] - CSS selector for the script element
6
- * @returns {object} Parsed JSON object, or empty object if not found / invalid
12
+ * @param {function} [validate] - Optional validator: (data) => boolean. If it
13
+ * returns false, hydrateState returns {} instead of the parsed data.
14
+ * @returns {object} Parsed JSON object, or empty object if not found / invalid / rejected
7
15
  *
8
16
  * @example
9
17
  * ```html
@@ -16,15 +24,35 @@
16
24
  * import { state } from 'lume-js';
17
25
  * import { hydrateState } from 'lume-js/addons';
18
26
  *
19
- * const store = state(hydrateState());
27
+ * // With optional schema validation
28
+ * const data = hydrateState('#__LUME_DATA__', d =>
29
+ * typeof d.title === 'string' && typeof d.count === 'number'
30
+ * );
31
+ * const store = state(data);
20
32
  * ```
21
33
  */
22
- export function hydrateState(selector = '#__LUME_DATA__') {
34
+ export function hydrateState(selector = '#__LUME_DATA__', validate) {
23
35
  const el = typeof document !== 'undefined' ? document.querySelector(selector) : null;
24
36
  if (!el) return {};
37
+
38
+ // Reject non-script elements or scripts without the correct type.
39
+ // This mitigates DOM clobbering where an attacker injects a matching
40
+ // element with a different tag name (e.g., a div or a script with
41
+ // a different type that would still match querySelector by id).
42
+ if (el.tagName !== 'SCRIPT' || el.type !== 'application/json') {
43
+ return {};
44
+ }
45
+
46
+ let data;
25
47
  try {
26
- return JSON.parse(el.textContent);
48
+ data = JSON.parse(el.textContent);
27
49
  } catch {
28
50
  return {};
29
51
  }
52
+
53
+ if (typeof validate === 'function' && !validate(data)) {
54
+ return {};
55
+ }
56
+
57
+ return data;
30
58
  }
@@ -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
- /** Element tag name or factory function (default: 'div') */
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
- * Uses duck-typing: checks for the presence of $subscribe.
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
+
@@ -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
- * Uses duck-typing: checks for the presence of $subscribe.
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' && typeof obj.$subscribe === 'function');
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
+ }
@@ -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|Function} [options.element='div'] - Element tag name or factory function
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
- if (typeof render !== 'function' && typeof create !== 'function') {
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
- // Call update for data binding (new and existing elements)
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 (update) {
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
  }
@@ -24,6 +24,10 @@
24
24
  * onNotify(key, value) — called before subscribers are notified
25
25
  * onSubscribe(key) — called when $subscribe is invoked
26
26
  *
27
+ * @security Plugins run with full application privilege. A plugin can read
28
+ * all state, alter any write, or suppress mutations. Only pass trusted objects.
29
+ * Plugin objects are frozen after registration to prevent post-init mutation.
30
+ *
27
31
  * @param {object} store - A reactive proxy from state()
28
32
  * @param {Array<object>} plugins - Array of plugin objects
29
33
  * @returns {Proxy} A new proxy wrapping the store with plugin behavior
@@ -33,13 +37,15 @@ import { logError } from '../utils/log.js';
33
37
  export function withPlugins(store, plugins = []) {
34
38
  if (!plugins.length) return store;
35
39
 
36
- // Call onInit hooks once at wrap time
40
+ // Call onInit hooks once at wrap time, then freeze each plugin to prevent
41
+ // post-registration mutation of its hooks (defense-in-depth).
37
42
  for (const p of plugins) {
38
43
  try {
39
44
  p.onInit?.();
40
45
  } catch (e) {
41
46
  logError(`[Lume.js] Plugin "${p.name}" error in onInit:`, e);
42
47
  }
48
+ Object.freeze(p);
43
49
  }
44
50
 
45
51
  // Track pending notifications for onNotify hooks.