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.
- package/AGENT_GUIDE.md +224 -0
- package/README.md +97 -16
- package/dist/addons.min.mjs +1 -1
- package/dist/addons.mjs +190 -7
- package/dist/addons.mjs.map +1 -1
- package/dist/handlers.min.mjs +1 -1
- package/dist/handlers.mjs +29 -2
- 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-BGg9PbiG.mjs +249 -0
- package/dist/shared-BGg9PbiG.mjs.map +1 -0
- package/dist/shared-DmpHYKx7.mjs +15 -0
- package/dist/shared-DmpHYKx7.mjs.map +1 -0
- package/dist/shared-SUXdsYBx.mjs +233 -0
- package/dist/shared-SUXdsYBx.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/llms-full.txt +6999 -0
- package/llms.txt +89 -0
- package/package.json +11 -2
- package/src/addons/index.d.ts +99 -7
- package/src/addons/index.js +13 -2
- package/src/addons/persist.js +190 -0
- package/src/addons/repeat.js +159 -10
- 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 +118 -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/handlers/stringAttr.js +9 -2
- package/src/index.d.ts +14 -200
- package/src/index.js +3 -1
- package/src/state.d.ts +252 -0
- package/src/state.js +25 -0
- package/dist/shared-x2HJmEyO.mjs +0 -260
- 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.
|
|
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
|
-
"
|
|
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": [
|
package/src/addons/index.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
*
|
|
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>(
|
|
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
|
-
* @
|
|
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
|
|
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,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
|
+
}
|