lume-js 2.2.1 → 2.3.1

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/AGENT_GUIDE.md +224 -0
  2. package/README.md +97 -16
  3. package/dist/addons.min.mjs +1 -1
  4. package/dist/addons.mjs +190 -7
  5. package/dist/addons.mjs.map +1 -1
  6. package/dist/handlers.min.mjs +1 -1
  7. package/dist/handlers.mjs +29 -2
  8. package/dist/handlers.mjs.map +1 -1
  9. package/dist/index.min.mjs +1 -1
  10. package/dist/index.mjs +4 -141
  11. package/dist/index.mjs.map +1 -1
  12. package/dist/lume.global.js +1 -1
  13. package/dist/lume.global.js.map +1 -1
  14. package/dist/shared-BGg9PbiG.mjs +249 -0
  15. package/dist/shared-BGg9PbiG.mjs.map +1 -0
  16. package/dist/shared-DmpHYKx7.mjs +15 -0
  17. package/dist/shared-DmpHYKx7.mjs.map +1 -0
  18. package/dist/shared-SUXdsYBx.mjs +233 -0
  19. package/dist/shared-SUXdsYBx.mjs.map +1 -0
  20. package/dist/state.min.mjs +1 -0
  21. package/dist/state.mjs +7 -0
  22. package/dist/state.mjs.map +1 -0
  23. package/llms-full.txt +6999 -0
  24. package/llms.txt +89 -0
  25. package/package.json +11 -2
  26. package/src/addons/index.d.ts +99 -7
  27. package/src/addons/index.js +13 -2
  28. package/src/addons/persist.js +190 -0
  29. package/src/addons/repeat.js +159 -10
  30. package/src/core/batch.js +139 -0
  31. package/src/core/bindDom.js +7 -3
  32. package/src/core/effect.js +34 -5
  33. package/src/core/state.js +118 -73
  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 +9 -2
  38. package/src/index.d.ts +14 -200
  39. package/src/index.js +3 -1
  40. package/src/state.d.ts +252 -0
  41. package/src/state.js +25 -0
  42. package/dist/shared-x2HJmEyO.mjs +0 -260
  43. package/dist/shared-x2HJmEyO.mjs.map +0 -1
package/llms.txt ADDED
@@ -0,0 +1,89 @@
1
+ # Lume.js
2
+
3
+ > Minimal reactive state management using only standard JavaScript and HTML —
4
+ > no custom syntax, no build step, no framework lock-in. 1.46 KB universal
5
+ > core (state/batch), 2.66 KB with DOM binding (bindDom/effect). Reactivity
6
+ > that follows web standards: stores are Proxies over plain objects, DOM
7
+ > binding is declarative via valid data-* attributes, updates are
8
+ > microtask-batched per store with an explicit batch() escape hatch for
9
+ > cross-store writes.
10
+
11
+ Version: 2.3.1. Install: `npm install lume-js` or import from
12
+ `https://cdn.jsdelivr.net/npm/lume-js/dist/index.min.mjs`.
13
+
14
+ Key facts agents get wrong without reading the docs: arrays and nested
15
+ objects must be REPLACED, never mutated in place (reference equality);
16
+ nested objects need explicit state() wrapping; updates flush on the next
17
+ microtask, not synchronously; effects only track keys read synchronously;
18
+ data-* attribute values are state keys, never expressions.
19
+
20
+ ## Start here
21
+
22
+ - [AGENT_GUIDE.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/AGENT_GUIDE.md): Distilled rules, pitfalls, and patterns for coding agents
23
+ - [README.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/README.md): Pitch, installation, quick start, API overview
24
+
25
+ ## Guides
26
+
27
+ - [docs/guides/installation.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/guides/installation.md): CDN, npm, and import options
28
+ - [docs/guides/quick-start.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/guides/quick-start.md): First app in five minutes
29
+ - [docs/guides/core-concepts.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/guides/core-concepts.md): Stores, effects, bindings
30
+ - [docs/guides/reactivity.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/guides/reactivity.md): How the Proxy, tracking, and microtask flush work
31
+ - [docs/guides/choosing-reactive-primitives.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/guides/choosing-reactive-primitives.md): effect vs computed vs watch
32
+ - [docs/guides/two-way-binding.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/guides/two-way-binding.md): Inputs and data-bind
33
+ - [docs/guides/lists.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/guides/lists.md): Rendering arrays
34
+ - [docs/guides/forms.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/guides/forms.md): Form patterns
35
+ - [docs/guides/handlers.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/guides/handlers.md): Extending bindDom with new data-* attributes
36
+ - [docs/guides/cleanup-and-dispose.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/guides/cleanup-and-dispose.md): Tearing down effects and bindings
37
+ - [docs/guides/performance.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/guides/performance.md): Batching and update costs
38
+ - [docs/guides/animations.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/guides/animations.md): Animating on state change
39
+ - [docs/guides/routing.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/guides/routing.md): Client-side routing patterns
40
+ - [docs/guides/ssr-hydration.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/guides/ssr-hydration.md): Server-rendered HTML with reactive hydration
41
+ - [docs/guides/universal-core.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/guides/universal-core.md): Using lume-js/state without a DOM
42
+ - [docs/guides/testing.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/guides/testing.md): Testing stores and bindings
43
+ - [docs/guides/migration.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/guides/migration.md): Migrating between versions
44
+ - [docs/guides/faq.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/guides/faq.md): Frequently asked questions
45
+
46
+ ## Tutorials
47
+
48
+ - [docs/tutorials/build-todo-app.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/tutorials/build-todo-app.md): Todo app walkthrough
49
+ - [docs/tutorials/working-with-arrays.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/tutorials/working-with-arrays.md): Immutable array updates — the golden rule
50
+ - [docs/tutorials/build-tic-tac-toe.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/tutorials/build-tic-tac-toe.md): Game with history and computed winner
51
+
52
+ ## API reference — core
53
+
54
+ - [docs/api/core/state.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/api/core/state.md): state() reactive stores
55
+ - [docs/api/core/effect.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/api/core/effect.md): effect() auto-tracking and explicit deps
56
+ - [docs/api/core/bindDom.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/api/core/bindDom.md): bindDom() DOM binding
57
+ - [docs/api/core/batch.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/api/core/batch.md): batch() cross-store write grouping
58
+ - [docs/api/core/handlers.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/api/core/handlers.md): Handler system — the { attr, apply } extension contract
59
+
60
+ ## API reference — handlers
61
+
62
+ - [docs/api/handlers/show.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/api/handlers/show.md): Conditional visibility
63
+ - [docs/api/handlers/className.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/api/handlers/className.md): Full class attribute from state
64
+ - [docs/api/handlers/boolAttr.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/api/handlers/boolAttr.md): Boolean attributes (disabled, hidden, …)
65
+ - [docs/api/handlers/ariaAttr.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/api/handlers/ariaAttr.md): ARIA attributes from state
66
+ - [docs/api/handlers/classToggle.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/api/handlers/classToggle.md): Toggle a single class
67
+ - [docs/api/handlers/stringAttr.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/api/handlers/stringAttr.md): String attributes (href, src, title, …)
68
+ - [docs/api/handlers/on.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/api/handlers/on.md): Declarative event wiring via data-on*
69
+ - [docs/api/handlers/htmlAttrs.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/api/handlers/htmlAttrs.md): Preset bundle of common attributes
70
+
71
+ ## API reference — addons
72
+
73
+ - [docs/api/addons/computed.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/api/addons/computed.md): Derived read-only values
74
+ - [docs/api/addons/watch.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/api/addons/watch.md): Single-key observation
75
+ - [docs/api/addons/repeat.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/api/addons/repeat.md): Keyed list rendering
76
+ - [docs/api/addons/persist.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/api/addons/persist.md): localStorage/sessionStorage sync
77
+ - [docs/api/addons/hydrateState.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/api/addons/hydrateState.md): SSR hydration
78
+ - [docs/api/addons/createCleanupGroup.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/api/addons/createCleanupGroup.md): Grouped disposal
79
+ - [docs/api/addons/debug.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/api/addons/debug.md): Write/flush logging
80
+ - [docs/api/addons/withPlugins.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/api/addons/withPlugins.md): State extension system
81
+ - [docs/api/addons/isReactive.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/api/addons/isReactive.md): Reactive brand detection
82
+
83
+ ## Design
84
+
85
+ - [docs/design/design-decisions.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/design/design-decisions.md): Ratified decisions and their reasoning
86
+
87
+ ## Optional
88
+
89
+ - [llms-full.txt](https://raw.githubusercontent.com/sathvikc/lume-js/main/llms-full.txt): every file above concatenated into one document
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lume-js",
3
- "version": "2.2.1",
3
+ "version": "2.3.1",
4
4
  "description": "Minimal reactive state management using only standard JavaScript and HTML - no custom syntax, no build step required",
5
5
  "main": "dist/index.mjs",
6
6
  "module": "dist/index.mjs",
@@ -14,6 +14,10 @@
14
14
  "import": "./dist/index.mjs",
15
15
  "types": "./src/index.d.ts"
16
16
  },
17
+ "./state": {
18
+ "import": "./dist/state.mjs",
19
+ "types": "./src/state.d.ts"
20
+ },
17
21
  "./addons": {
18
22
  "import": "./dist/addons.mjs",
19
23
  "types": "./src/addons/index.d.ts"
@@ -39,13 +43,18 @@
39
43
  "test:watch": "vitest",
40
44
  "coverage": "vitest run --coverage",
41
45
  "typecheck": "tsc --noEmit",
42
- "validate": "npm run build && node scripts/check-size.js && node scripts/complexity.js && npm run lint && npm run typecheck && npm run coverage",
46
+ "llms": "node scripts/build-llms.js",
47
+ "llms:check": "node scripts/build-llms.js --check",
48
+ "validate": "npm run build && node scripts/check-size.js && node scripts/complexity.js && npm run lint && npm run typecheck && npm run coverage && node scripts/build-llms.js --check",
43
49
  "prepublishOnly": "npm run validate"
44
50
  },
45
51
  "files": [
46
52
  "dist",
47
53
  "src",
48
54
  "README.md",
55
+ "AGENT_GUIDE.md",
56
+ "llms.txt",
57
+ "llms-full.txt",
49
58
  "LICENSE"
50
59
  ],
51
60
  "keywords": [
@@ -153,9 +153,11 @@ export interface RepeatOptions<T> {
153
153
 
154
154
  /**
155
155
  * Function called ONCE when element is created (for DOM structure setup)
156
- * Recommended for event listeners and innerHTML setup
156
+ * Recommended for event listeners and innerHTML setup.
157
+ * May return a cleanup function — it is called automatically when the
158
+ * element is removed (by list update or full cleanup).
157
159
  */
158
- create?: (item: T, element: HTMLElement, index: number) => void;
160
+ create?: (item: T, element: HTMLElement, index: number) => void | (() => void);
159
161
 
160
162
  /**
161
163
  * Function called on every update for data binding
@@ -163,7 +165,32 @@ export interface RepeatOptions<T> {
163
165
  */
164
166
  update?: (item: T, element: HTMLElement, index: number, context: UpdateContext) => void;
165
167
 
166
- /** Element tag name or factory function (default: 'div') */
168
+ /**
169
+ * Additional cleanup when an element is removed. Called after any cleanup
170
+ * function returned by create(). Prefer returning a cleanup from create()
171
+ * for automatic lifecycle management.
172
+ */
173
+ remove?: (item: T, element: HTMLElement) => void;
174
+
175
+ /**
176
+ * Declarative item structure from a standard <template> element.
177
+ * - true: use the first <template> inside the container
178
+ * - string: CSS selector resolving to a <template>
179
+ * - HTMLTemplateElement: use directly
180
+ *
181
+ * The template must contain exactly one root element. It is cloned per
182
+ * item, and its [data-bind] paths are bound against the ITEM on every
183
+ * update: "name" → item.name, "user.city" → item.user.city,
184
+ * "$item" → the item itself, "$index" → current index.
185
+ * Inputs receive .value/.checked; other elements receive textContent
186
+ * (same semantics as bindDom's data-bind). One-way snapshot bindings.
187
+ *
188
+ * When set: options.element is ignored, options.render is ignored (with
189
+ * a console warning); create/update still run on top of the bindings.
190
+ */
191
+ template?: true | string | HTMLTemplateElement;
192
+
193
+ /** Element tag name or factory function (default: 'div'; ignored when template is set) */
167
194
  element?: string | (() => HTMLElement);
168
195
 
169
196
  /**
@@ -396,7 +423,8 @@ export const debug: Debug;
396
423
 
397
424
  /**
398
425
  * Returns true if the value is a Lume reactive proxy created by state().
399
- * Uses duck-typing: checks for the presence of $subscribe.
426
+ * Checks the shared reactive brand symbol first (Symbol.for('lume.reactive')),
427
+ * then falls back to duck-typing ($subscribe) for older stores.
400
428
  * This is a type guard that narrows the type to ReactiveState.
401
429
  *
402
430
  * @param obj - Value to check
@@ -439,6 +467,9 @@ export interface Plugin {
439
467
  * Wrap a reactive state proxy with plugin hooks.
440
468
  * Plugins can intercept get/set/notify/subscribe operations.
441
469
  *
470
+ * The returned proxy additionally exposes $dispose(), which removes the
471
+ * plugin layer's flush hook and clears its pending notifications.
472
+ *
442
473
  * @param store - A reactive proxy from state()
443
474
  * @param plugins - Array of plugin objects
444
475
  * @returns A new proxy wrapping the store with plugin behavior
@@ -449,9 +480,13 @@ export interface Plugin {
449
480
  * import { withPlugins, createDebugPlugin } from 'lume-js/addons';
450
481
  *
451
482
  * const store = withPlugins(state({ count: 0 }), [createDebugPlugin({ label: 'counter' })]);
483
+ * store.$dispose(); // detach the plugin layer when done
452
484
  * ```
453
485
  */
454
- export function withPlugins<T extends object>(store: ReactiveState<T>, plugins: Plugin[]): ReactiveState<T>;
486
+ export function withPlugins<T extends object>(
487
+ store: ReactiveState<T>,
488
+ plugins: Plugin[]
489
+ ): ReactiveState<T> & { $dispose(): void };
455
490
 
456
491
  /**
457
492
  * A group that collects cleanup/unsubscribe functions and can dispose them all at once.
@@ -489,7 +524,9 @@ export function createCleanupGroup(): CleanupGroup;
489
524
  * embedded in the server-rendered HTML.
490
525
  *
491
526
  * @param selector - CSS selector for the script element (default: '#__LUME_DATA__')
492
- * @returns Parsed JSON object, or empty object if not found or invalid
527
+ * @param validate - Optional validator: (data) => boolean. If it returns
528
+ * false, hydrateState returns {} instead of the parsed data.
529
+ * @returns Parsed JSON object, or empty object if not found, invalid, or rejected
493
530
  *
494
531
  * @example
495
532
  * ```typescript
@@ -497,7 +534,62 @@ export function createCleanupGroup(): CleanupGroup;
497
534
  * import { hydrateState } from 'lume-js/addons';
498
535
  *
499
536
  * const store = state(hydrateState());
537
+ *
538
+ * // With schema validation
539
+ * const data = hydrateState('#__LUME_DATA__', d =>
540
+ * typeof (d as any).title === 'string'
541
+ * );
500
542
  * ```
501
543
  */
502
- export function hydrateState(selector?: string): object;
544
+ export function hydrateState(selector?: string, validate?: (data: unknown) => boolean): object;
545
+
546
+ /**
547
+ * Options for persist()
548
+ */
549
+ export interface PersistOptions {
550
+ /**
551
+ * Keys to persist. Default: all own non-$ keys of the store at call time.
552
+ * An explicit empty array is respected: persists and hydrates nothing.
553
+ */
554
+ keys?: string[];
555
+ /**
556
+ * Storage object (localStorage, sessionStorage, or any Storage-like
557
+ * object with getItem/setItem). Default: localStorage.
558
+ */
559
+ storage?: Pick<Storage, 'getItem' | 'setItem'> | null;
560
+ }
561
+
562
+ /**
563
+ * Keep selected store keys in sync with localStorage/sessionStorage:
564
+ * hydrates them on call, then saves on change.
565
+ *
566
+ * - Hydration assigns stored values through the proxy (subscribers fire).
567
+ * - Saves coalesce to one storage write per microtask and are skipped when
568
+ * the serialized snapshot is unchanged.
569
+ * - Storage failures (quota, corrupted JSON, unserializable values) warn
570
+ * on the console — never throw.
571
+ * - With no storage available (SSR), persistence is disabled with a warning.
572
+ *
573
+ * @param store - Reactive store created with state()
574
+ * @param storageKey - The storage entry name to read/write
575
+ * @param options - Persist options
576
+ * @returns Dispose function — stops watching and saving
577
+ * @throws {Error} If store is not reactive or storageKey is empty
578
+ *
579
+ * @example
580
+ * ```typescript
581
+ * import { state } from 'lume-js';
582
+ * import { persist } from 'lume-js/addons';
583
+ *
584
+ * const store = state({ todos: [], filter: 'all', draft: '' });
585
+ *
586
+ * // Hydrate + auto-save todos/filter; draft stays in-memory only
587
+ * const stop = persist(store, 'my-app', { keys: ['todos', 'filter'] });
588
+ * ```
589
+ */
590
+ export function persist(
591
+ store: ReactiveState<any>,
592
+ storageKey: string,
593
+ options?: PersistOptions
594
+ ): Unsubscribe;
503
595
 
@@ -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,190 @@
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
+ // storage object → Set of storage keys currently managed by a persist()
57
+ // instance. Two instances on one entry silently overwrite each other's
58
+ // subsets (each serializes only its own watched keys over the whole blob),
59
+ // so the second registration gets a loud warning.
60
+ const activeEntries = new WeakMap();
61
+
62
+ /**
63
+ * Default storage resolution. Wrapped because accessing localStorage can
64
+ * THROW (SecurityError) in cookie-blocked iframes and some privacy modes —
65
+ * persist() must degrade to a warning, never crash the app at setup.
66
+ */
67
+ function defaultStorage() {
68
+ try {
69
+ return typeof localStorage !== 'undefined' ? localStorage : undefined;
70
+ } catch {
71
+ return undefined;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Sync store keys with a Storage object.
77
+ *
78
+ * @param {object} store - Reactive store created with state()
79
+ * @param {string} storageKey - The storage entry name to read/write
80
+ * @param {object} [options]
81
+ * @param {string[]} [options.keys] - Keys to persist. Default: all own
82
+ * non-$ keys of the store at call time.
83
+ * @param {Storage} [options.storage] - Storage object. Default: localStorage.
84
+ * Pass sessionStorage for per-tab persistence.
85
+ * @returns {function} Dispose function — stops watching and saving.
86
+ */
87
+ export function persist(store, storageKey, options = {}) {
88
+ if (!store || typeof store.$subscribe !== 'function') {
89
+ throw new Error('[Lume.js] persist() requires a reactive store from state()');
90
+ }
91
+ if (typeof storageKey !== 'string' || storageKey.length === 0) {
92
+ throw new Error('[Lume.js] persist() requires a non-empty storage key');
93
+ }
94
+
95
+ const storage = options.storage !== undefined
96
+ ? options.storage
97
+ : defaultStorage();
98
+
99
+ if (!storage || typeof storage.getItem !== 'function') {
100
+ logWarn('[Lume.js] persist(): no storage available — persistence disabled');
101
+ return () => {};
102
+ }
103
+
104
+ // An explicit array is respected as-is — including [] (persist nothing).
105
+ // Only an absent/non-array option falls back to all own non-$ keys.
106
+ const watched = Array.isArray(options.keys)
107
+ ? options.keys.slice()
108
+ : Object.keys(store).filter(k => !k.startsWith('$'));
109
+
110
+ // Warn when another live persist() already manages this storage entry
111
+ let entrySet = activeEntries.get(storage);
112
+ if (!entrySet) {
113
+ entrySet = new Set();
114
+ activeEntries.set(storage, entrySet);
115
+ }
116
+ const ownsEntry = !entrySet.has(storageKey);
117
+ if (ownsEntry) {
118
+ entrySet.add(storageKey);
119
+ } else {
120
+ logWarn(
121
+ `[Lume.js] persist(): "${storageKey}" is already managed by another persist() on this storage — ` +
122
+ 'instances will overwrite each other\'s data. Use a distinct key per store.'
123
+ );
124
+ }
125
+
126
+ // ── Hydrate ────────────────────────────────────────────────────────────
127
+ // Only watched keys are assigned — stale storage can't inject others.
128
+ const stored = readStored(storage, storageKey);
129
+ if (stored) {
130
+ for (const k of watched) {
131
+ if (Object.prototype.hasOwnProperty.call(stored, k)) {
132
+ store[k] = stored[k];
133
+ }
134
+ }
135
+ }
136
+
137
+ // ── Save on change ─────────────────────────────────────────────────────
138
+ // Remember what storage holds (post-hydration) so unchanged flushes —
139
+ // including the hydration echo itself — skip the write.
140
+ let lastWritten = null;
141
+ try {
142
+ lastWritten = serializeKeys(store, watched);
143
+ } catch {
144
+ // Unserializable initial state: first save attempt will warn.
145
+ }
146
+
147
+ let scheduled = false;
148
+ let disposed = false;
149
+
150
+ const flushSave = () => {
151
+ scheduled = false;
152
+ if (disposed) return;
153
+
154
+ let json;
155
+ try {
156
+ json = serializeKeys(store, watched);
157
+ } catch (err) {
158
+ logWarn('[Lume.js] persist(): state not serializable — skipping save', err);
159
+ return;
160
+ }
161
+ if (json === lastWritten) return;
162
+
163
+ try {
164
+ storage.setItem(storageKey, json);
165
+ lastWritten = json;
166
+ } catch (err) {
167
+ logWarn('[Lume.js] persist(): could not write — storage full or unavailable?', err);
168
+ }
169
+ };
170
+
171
+ const save = () => {
172
+ if (scheduled) return;
173
+ scheduled = true;
174
+ queueMicrotask(flushSave);
175
+ };
176
+
177
+ const unsubs = watched.map(k => {
178
+ let first = true;
179
+ return store.$subscribe(k, () => {
180
+ if (first) { first = false; return; } // skip $subscribe's immediate call
181
+ save();
182
+ });
183
+ });
184
+
185
+ return () => {
186
+ disposed = true;
187
+ if (ownsEntry) entrySet.delete(storageKey);
188
+ while (unsubs.length) unsubs.pop()();
189
+ };
190
+ }