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/src/core/state.js CHANGED
@@ -12,6 +12,7 @@
12
12
  * - $subscribe for listening to key changes
13
13
  * - Cleanup with unsubscribe
14
14
  * - Per-state microtask batching for writes
15
+ * - batch() for grouping writes across states with cross-store effect dedupe
15
16
  * - Scope-based read tracking via withReadObserver (multi-observer safe)
16
17
  *
17
18
  * Usage:
@@ -23,6 +24,7 @@
23
24
  */
24
25
 
25
26
  import { logError, logWarn } from '../utils/log.js';
27
+ import { enqueueIfBatching, MAX_FLUSH_ITERATIONS } from './batch.js';
26
28
 
27
29
  // Per-state batching – each state object maintains its own microtask flush.
28
30
  // This keeps effects simple and aligned with Lume's minimal philosophy.
@@ -48,6 +50,22 @@ import { logError, logWarn } from '../utils/log.js';
48
50
  // own independent Set via ES module / CommonJS isolation.
49
51
  const readers = new Set();
50
52
 
53
+ /**
54
+ * Brand symbol stamped on every object passed to state().
55
+ *
56
+ * Uses the global symbol registry (Symbol.for) so independent copies of
57
+ * lume-js on the same page (e.g. a CDN build next to a bundled chunk)
58
+ * agree on the same brand. This is a type tag for reliable detection
59
+ * (see isReactive in addons), not a security boundary — any code can
60
+ * stamp it.
61
+ *
62
+ * Internal API — exported for addons; not re-exported from the package root.
63
+ */
64
+ export const REACTIVE_BRAND = Symbol.for('lume.reactive');
65
+
66
+ // batch() lives in ./batch.js (which never imports this module — no cycle).
67
+ // state.js participates through enqueueIfBatching in scheduleFlush below.
68
+
51
69
  /**
52
70
  * Run a function with a read observer active.
53
71
  * The observer receives (proxy, key, registerEffect) for every property read.
@@ -89,6 +107,57 @@ export function state(obj) {
89
107
  const beforeFlushHooks = [];
90
108
  let flushScheduled = false;
91
109
 
110
+ // ── Flush steps ──────────────────────────────────────────────────────
111
+ // Named pieces shared by the per-state microtask flush and batch().
112
+
113
+ function runBeforeFlushHooks() {
114
+ for (let i = 0; i < beforeFlushHooks.length; i++) {
115
+ try {
116
+ beforeFlushHooks[i]();
117
+ } catch (err) {
118
+ logError('[Lume.js state] Error in beforeFlush hook:', err);
119
+ }
120
+ }
121
+ }
122
+
123
+ function notifySubscribers() {
124
+ // Drain BEFORE delivering. Iterating the live Map is wrong twice over:
125
+ // a subscriber writing back to an already-delivered key would update
126
+ // the in-flight entry (never revisited) and the trailing clear() would
127
+ // destroy it — a silently lost update; and a subscriber opening a
128
+ // batch() would re-deliver every in-flight entry through the wave's
129
+ // re-entrant call. Draining first means write-backs land in the now-
130
+ // empty live Map and are delivered by the next flush iteration/wave.
131
+ const entries = Array.from(pendingNotifications);
132
+ pendingNotifications.clear();
133
+ for (const [key, value] of entries) {
134
+ if (listeners[key]) {
135
+ const subs = listeners[key];
136
+ let i = 0;
137
+ while (i < subs.length) {
138
+ const fn = subs[i];
139
+ try {
140
+ fn(value);
141
+ } catch (err) {
142
+ logError(`[Lume.js state] Error notifying subscriber for key "${String(key)}":`, err);
143
+ }
144
+ // Only advance if fn wasn't removed (something shifted into its place)
145
+ if (subs[i] === fn) i++;
146
+ }
147
+ }
148
+ }
149
+ }
150
+
151
+ /** Drain queued effects (Set deduplicates) into an array. */
152
+ function takeEffects() {
153
+ const effects = Array.from(pendingEffects);
154
+ pendingEffects.clear();
155
+ return effects;
156
+ }
157
+
158
+ // Handle this state gives batch() — flush steps only, no live queues.
159
+ const batchHandle = { runBeforeFlushHooks, notifySubscribers, takeEffects };
160
+
92
161
  /**
93
162
  * Schedule a single microtask flush for this state object.
94
163
  *
@@ -100,57 +169,28 @@ export function state(obj) {
100
169
  *
101
170
  * Notes:
102
171
  * - Batching is per state; effects that depend on multiple states
103
- * may run once per state that changed (by design).
172
+ * may run once per state that changed (by design). Use batch() to
173
+ * group writes across states and run such effects once.
174
+ * - Inside batch(), the microtask is skipped: the state enqueues
175
+ * itself for the synchronous flush at the end of the batch.
104
176
  */
105
177
  function scheduleFlush() {
178
+ // Inside batch(): the batch captures this state's flush handle and
179
+ // flushes synchronously at the end — skip the microtask.
180
+ if (enqueueIfBatching(batchHandle)) return;
181
+
106
182
  if (flushScheduled) return;
107
183
 
108
184
  flushScheduled = true;
109
- // eslint-disable-next-line sonarjs/cognitive-complexity -- single-pass flush loop: hooks → subscribers → effects → cycle detection; must stay atomic
110
185
  queueMicrotask(() => {
111
186
  let iterations = 0;
112
- const MAX_ITERATIONS = 100;
113
187
 
114
188
  try {
115
- while ((pendingNotifications.size > 0 || pendingEffects.size > 0) && iterations < MAX_ITERATIONS) {
189
+ while ((pendingNotifications.size > 0 || pendingEffects.size > 0) && iterations < MAX_FLUSH_ITERATIONS) {
116
190
  iterations++;
117
-
118
- // Run registered before-flush hooks (e.g. plugin onNotify)
119
- for (let i = 0; i < beforeFlushHooks.length; i++) {
120
- try {
121
- beforeFlushHooks[i]();
122
- } catch (err) {
123
- logError('[Lume.js state] Error in beforeFlush hook:', err);
124
- }
125
- }
126
-
127
- // Notify all subscribers of changed keys
128
- for (const [key, value] of pendingNotifications) {
129
- if (listeners[key]) {
130
- const subs = listeners[key];
131
- let i = 0;
132
- while (i < subs.length) {
133
- const fn = subs[i];
134
- try {
135
- fn(value);
136
- } catch (err) {
137
- logError(`[Lume.js state] Error notifying subscriber for key "${String(key)}":`, err);
138
- }
139
- // Only advance if fn wasn't removed (something shifted into its place)
140
- if (subs[i] === fn) i++;
141
- }
142
- }
143
- }
144
-
145
- pendingNotifications.clear();
146
-
147
- // Run each effect exactly once (Set deduplicates)
148
- const effects = new Array(pendingEffects.size);
149
- let idx = 0;
150
- for (const effect of pendingEffects) {
151
- effects[idx++] = effect;
152
- }
153
- pendingEffects.clear();
191
+ runBeforeFlushHooks();
192
+ notifySubscribers();
193
+ const effects = takeEffects();
154
194
  for (let i = 0; i < effects.length; i++) {
155
195
  try {
156
196
  effects[i]();
@@ -163,7 +203,7 @@ export function state(obj) {
163
203
  flushScheduled = false;
164
204
  }
165
205
 
166
- if (iterations >= MAX_ITERATIONS) {
206
+ if (iterations >= MAX_FLUSH_ITERATIONS) {
167
207
  logError(
168
208
  '[Lume.js state] Maximum flush iterations reached (100). ' +
169
209
  'This usually indicates an infinite loop caused by an effect or computed mutating state it depends on.'
@@ -172,29 +212,47 @@ export function state(obj) {
172
212
  });
173
213
  }
174
214
 
175
- // Brand symbol for type-level reactive identification
176
- const REACTIVE_BRAND = Symbol('lume.reactive');
177
- obj[REACTIVE_BRAND] = true;
215
+ // Stamp the shared brand (non-enumerable: spreads/Object.assign copies
216
+ // of a store do not inherit the brand and won't masquerade as reactive).
217
+ Object.defineProperty(obj, REACTIVE_BRAND, { value: true });
178
218
 
179
- // Defined once per state instance — not per property read — to avoid per-read closure allocation.
180
- const registerEffect = (key, executeFn) => {
181
- if (!listeners[key]) listeners[key] = [];
182
-
183
- const callback = () => {
184
- pendingEffects.add(executeFn);
185
- };
186
-
187
- listeners[key].push(callback);
219
+ const MAX_SUBSCRIBERS = 1000;
220
+ const noopUnsubscribe = () => {};
188
221
 
222
+ /**
223
+ * Shared listener registration with a per-key cap (subscriber DoS
224
+ * protection). Applied identically to $subscribe callbacks and effect
225
+ * subscriptions so both paths degrade the same way: a loud console
226
+ * error and a no-op unsubscribe.
227
+ */
228
+ function addListener(key, fn, kind) {
229
+ if (!listeners[key]) listeners[key] = [];
230
+ if (listeners[key].length >= MAX_SUBSCRIBERS) {
231
+ logError(
232
+ `[Lume.js state] Subscriber limit (${MAX_SUBSCRIBERS}) reached for key "${String(key)}". ` +
233
+ `${kind} ignored — it will NOT receive updates. ` +
234
+ 'This usually means subscriptions are created in a loop without cleanup.'
235
+ );
236
+ return noopUnsubscribe;
237
+ }
238
+ listeners[key].push(fn);
189
239
  return () => {
190
240
  if (listeners[key]) {
191
- const idx = listeners[key].indexOf(callback);
241
+ const idx = listeners[key].indexOf(fn);
192
242
  if (idx !== -1) {
193
243
  listeners[key].splice(idx, 1);
194
244
  if (listeners[key].length === 0) delete listeners[key];
195
245
  }
196
246
  }
197
247
  };
248
+ }
249
+
250
+ // Defined once per state instance — not per property read — to avoid per-read closure allocation.
251
+ const registerEffect = (key, executeFn) => {
252
+ const callback = () => {
253
+ pendingEffects.add(executeFn);
254
+ };
255
+ return addListener(key, callback, 'Effect subscription');
198
256
  };
199
257
 
200
258
  const BLOCKED_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
@@ -269,33 +327,20 @@ export function state(obj) {
269
327
  };
270
328
  };
271
329
 
272
- const MAX_SUBSCRIBERS = 1000;
273
-
274
330
  obj.$subscribe = (key, fn) => {
275
331
  if (typeof fn !== 'function') {
276
332
  throw new Error('Subscriber must be a function');
277
333
  }
278
334
 
279
- if (!listeners[key]) listeners[key] = [];
280
- if (listeners[key].length >= MAX_SUBSCRIBERS) {
281
- logWarn(`[Lume.js state] Subscriber limit (${MAX_SUBSCRIBERS}) reached for key "${key}". New subscriber ignored.`);
282
- return () => {};
283
- }
284
- listeners[key].push(fn);
335
+ const unsubscribe = addListener(key, fn, 'New subscriber');
336
+
337
+ // Over the cap: listener was not added, skip the immediate call too
338
+ if (unsubscribe === noopUnsubscribe) return unsubscribe;
285
339
 
286
340
  // Call immediately with current value (NOT batched)
287
341
  fn(proxy[key]);
288
342
 
289
- // Return unsubscribe function
290
- return () => {
291
- if (listeners[key]) {
292
- const idx = listeners[key].indexOf(fn);
293
- if (idx !== -1) {
294
- listeners[key].splice(idx, 1);
295
- if (listeners[key].length === 0) delete listeners[key];
296
- }
297
- }
298
- };
343
+ return unsubscribe;
299
344
  };
300
345
 
301
346
  return proxy;
@@ -85,6 +85,30 @@ export function classToggle(...names: string[]): Handler[];
85
85
  */
86
86
  export function stringAttr(name: string): Handler;
87
87
 
88
+ /**
89
+ * Create handlers for declarative event wiring.
90
+ * Each type creates a handler: data-on{type}="key" wires the function held
91
+ * at that store key as a DOM event listener.
92
+ *
93
+ * Reactive like any binding: assigning a new function to the key re-wires
94
+ * the listener; assigning null/undefined detaches it. Non-function truthy
95
+ * values detach with a console warning.
96
+ *
97
+ * Returns an array — pass directly to handlers (auto-flattened by bindDom).
98
+ *
99
+ * @param types - DOM event types ('click', 'input', 'submit', ...)
100
+ *
101
+ * @example
102
+ * ```typescript
103
+ * const store = state({
104
+ * addTodo: (event: Event) => { ... }
105
+ * });
106
+ * bindDom(root, store, { handlers: [on('click')] });
107
+ * // <button data-onclick="addTodo">Add</button>
108
+ * ```
109
+ */
110
+ export function on(...types: string[]): Handler[];
111
+
88
112
  /** Form-related handlers preset (readonly) */
89
113
  export const formHandlers: Handler[];
90
114
 
@@ -25,5 +25,6 @@ export { boolAttr } from './boolAttr.js';
25
25
  export { ariaAttr } from './ariaAttr.js';
26
26
  export { classToggle } from './classToggle.js';
27
27
  export { stringAttr } from './stringAttr.js';
28
+ export { on } from './on.js';
28
29
  export { htmlAttrs } from './htmlAttrs.js';
29
30
  export { formHandlers, a11yHandlers } from './presets.js';
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Create handlers for declarative event wiring.
3
+ * Each type creates a handler: data-on{type}="key" wires the function held
4
+ * at that store key as a DOM event listener.
5
+ *
6
+ * <button data-onclick="addTodo">Add</button>
7
+ *
8
+ * const store = state({
9
+ * addTodo: (event) => { ... } // a plain function in state
10
+ * });
11
+ * bindDom(root, store, { handlers: [on('click')] });
12
+ *
13
+ * Reactive like any binding: assigning a new function to the key re-wires
14
+ * the listener; assigning null/undefined detaches it.
15
+ *
16
+ * Returns an array — pass directly to handlers (auto-flattened by bindDom).
17
+ *
18
+ * @security Same trust model as data-bind: an injected data-on* attribute
19
+ * can only reference functions that already exist in reachable state — no
20
+ * expressions, no eval. Ensure your HTML is trusted or sanitized.
21
+ *
22
+ * Note: bindDom's cleanup stops future re-wiring but does not detach
23
+ * listeners already attached to elements that remain in the DOM. Discard
24
+ * the bound subtree (the normal SPA teardown) to drop them.
25
+ *
26
+ * @param {...string} types - DOM event types ('click', 'input', 'submit', ...)
27
+ * @returns {Array<{ attr: string, apply: function }>}
28
+ */
29
+
30
+ import { logWarn } from '../utils/log.js';
31
+
32
+ // element → Map<eventType, listener> — tracks what we attached so a
33
+ // re-assigned store key swaps the listener instead of stacking a second one.
34
+ const attached = new WeakMap();
35
+
36
+ export function on(...types) {
37
+ return types.map(type => ({
38
+ attr: `data-on${type}`,
39
+ apply(el, val) {
40
+ let byType = attached.get(el);
41
+ const prev = byType ? byType.get(type) : undefined;
42
+
43
+ if (prev) {
44
+ el.removeEventListener(type, prev);
45
+ byType.delete(type);
46
+ }
47
+
48
+ if (typeof val === 'function') {
49
+ el.addEventListener(type, val);
50
+ if (!byType) {
51
+ byType = new Map();
52
+ attached.set(el, byType);
53
+ }
54
+ byType.set(type, val);
55
+ } else if (val != null) {
56
+ logWarn(`[Lume.js] on('${type}'): bound value is not a function — listener detached`);
57
+ }
58
+ }
59
+ }));
60
+ }
@@ -6,7 +6,13 @@
6
6
  * @returns {{ attr: string, apply: function }}
7
7
  */
8
8
 
9
- const DANGEROUS_SCHEME = /^(javascript|vbscript|data\s*:\s*text\/html)/i;
9
+ // Matched against a normalized copy of the value (C0 controls, space, and
10
+ // DEL stripped; lowercased) because the browser URL parser ignores those
11
+ // characters when determining the scheme — "java\tscript:alert(1)" and
12
+ // " javascript:alert(1)" both execute. The colon is required so legitimate
13
+ // relative URLs like "javascript-tutorial.html" are not blocked.
14
+ const DANGEROUS_SCHEME = /^(?:javascript:|vbscript:|data:text\/html)/;
15
+ const IGNORED_URL_CHARS = /[\u0000-\u0020\u007F]/g;
10
16
  const URI_ATTRS = new Set(['href', 'src', 'action', 'srcset', 'poster', 'formaction']);
11
17
 
12
18
  export function stringAttr(name) {
@@ -18,7 +24,8 @@ export function stringAttr(name) {
18
24
  return;
19
25
  }
20
26
  const strVal = String(val);
21
- if (URI_ATTRS.has(name) && DANGEROUS_SCHEME.test(strVal)) {
27
+ if (URI_ATTRS.has(name) &&
28
+ DANGEROUS_SCHEME.test(strVal.replace(IGNORED_URL_CHARS, '').toLowerCase())) {
22
29
  el.removeAttribute(name);
23
30
  return;
24
31
  }
package/src/index.d.ts CHANGED
@@ -4,186 +4,23 @@
4
4
  * Provides type safety for reactive state management
5
5
  */
6
6
 
7
- /**
8
- * Unsubscribe function returned by $subscribe
9
- */
10
- export type Unsubscribe = () => void;
11
-
12
- /**
13
- * Subscriber callback function
14
- */
15
- export type Subscriber<T> = (value: T) => void;
16
-
17
- /**
18
- * Internal unique symbol for reactive state branding
19
- * @internal
20
- */
21
- declare const lumeReactiveSymbol: unique symbol;
22
-
23
- /**
24
- * Plugin interface for extending state behavior
25
- *
26
- * All hooks are optional. Hooks execute in the order plugins are registered.
27
- *
28
- * @example
29
- * ```typescript
30
- * const debugPlugin: Plugin = {
31
- * name: 'debug',
32
- * onGet: (key, value) => {
33
- * console.log(`GET ${key}:`, value);
34
- * return value; // Return value to pass to next plugin
35
- * },
36
- * onSet: (key, newValue, oldValue) => {
37
- * console.log(`SET ${key}:`, oldValue, '→', newValue);
38
- * return newValue; // Return value to pass to next plugin
39
- * }
40
- * };
41
- *
42
- * const store = withPlugins(state({ count: 0 }), [debugPlugin]);
43
- * ```
44
- */
45
- export interface Plugin {
46
- /**
47
- * Plugin name (for debugging)
48
- */
49
- name: string;
50
-
51
- /**
52
- * Called when state object is created
53
- * Runs synchronously before Proxy is returned
54
- */
55
- onInit?(): void;
56
-
57
- /**
58
- * Called when a property is accessed (before value returned)
59
- * Can transform the value by returning a new value
60
- *
61
- * Chain pattern: Each plugin receives the output of the previous plugin
62
- *
63
- * @param key - Property key being accessed
64
- * @param value - Current value (possibly transformed by previous plugins)
65
- * @returns Transformed value, or undefined to keep current value
66
- */
67
- onGet?(key: string, value: any): any;
68
-
69
- /**
70
- * Called when a property is updated (before subscribers notified)
71
- * Can transform or validate the new value
72
- *
73
- * Chain pattern: Each plugin receives the output of the previous plugin
74
- *
75
- * @param key - Property key being updated
76
- * @param newValue - New value being set (possibly transformed by previous plugins)
77
- * @param oldValue - Previous value
78
- * @returns Transformed value, or undefined to keep current value
79
- */
80
- onSet?(key: string, newValue: any, oldValue: any): any;
81
-
82
- /**
83
- * Called when a subscriber is added
84
- * Useful for tracking active subscriptions
85
- *
86
- * @param key - Property key being subscribed to
87
- */
88
- onSubscribe?(key: string): void;
89
-
90
- /**
91
- * Called when subscribers are about to be notified
92
- * Runs in microtask, before subscribers receive value
93
- *
94
- * @param key - Property key that changed
95
- * @param value - New value being notified
96
- */
97
- onNotify?(key: string, value: any): void;
98
- }
99
-
100
- /**
101
- * Type-safe plugin interface for when you know the state shape.
102
- * Provides better intellisense for key names and value types.
103
- *
104
- * @example
105
- * ```typescript
106
- * interface AppState {
107
- * count: number;
108
- * name: string;
109
- * }
110
- *
111
- * const myPlugin: TypedPlugin<AppState> = {
112
- * name: 'typed',
113
- * onSet: (key, newValue, oldValue) => {
114
- * // key is 'count' | 'name', values are properly typed
115
- * return newValue;
116
- * }
117
- * };
118
- * ```
119
- */
120
- export interface TypedPlugin<T extends object> {
121
- name: string;
122
- onInit?(): void;
123
- onGet?<K extends keyof T>(key: K, value: T[K]): T[K] | undefined;
124
- onSet?<K extends keyof T>(key: K, newValue: T[K], oldValue: T[K]): T[K] | undefined;
125
- onSubscribe?<K extends keyof T>(key: K): void;
126
- onNotify?<K extends keyof T>(key: K, value: T[K]): void;
127
- }
7
+ // ── Kernel types & functions ─────────────────────────────────────────────
8
+ // The DOM-free kernel (state, batch, withReadObserver and their types)
9
+ // lives in ./state.d.ts — the source of truth for the `lume-js/state`
10
+ // entry, which must type-check without lib.dom (Node/worker consumers).
11
+ // This entry re-exports the kernel and adds the DOM-flavored API.
128
12
 
129
- /**
130
- * Reactive state object with $subscribe method
131
- */
132
- export type ReactiveState<T extends object> = T & {
133
- /**
134
- * Subscribe to changes on a specific property key
135
- * @param key - Property key to watch
136
- * @param callback - Function called when property changes
137
- * @returns Unsubscribe function for cleanup
138
- */
139
- $subscribe<K extends keyof T>(
140
- key: K,
141
- callback: Subscriber<T[K]>
142
- ): Unsubscribe;
13
+ export type {
14
+ Unsubscribe,
15
+ Subscriber,
16
+ Plugin,
17
+ TypedPlugin,
18
+ ReactiveState,
19
+ } from './state.js';
143
20
 
144
- /**
145
- * Register a callback to run before each flush.
146
- * Dedupes duplicate function references.
147
- * @param fn - Callback function
148
- * @returns Unsubscribe function for cleanup
149
- */
150
- $beforeFlush(fn: () => void): Unsubscribe;
21
+ export { state, batch, withReadObserver } from './state.js';
151
22
 
152
- /**
153
- * Brand to identify reactive state objects at the type level
154
- * @internal
155
- */
156
- readonly [lumeReactiveSymbol]?: true;
157
- };
158
-
159
- /**
160
- * Create a reactive state object
161
- *
162
- * @param obj - Plain object to make reactive
163
- * @returns Reactive proxy with $subscribe method
164
- * @throws {Error} If obj is not a plain object
165
- *
166
- * @example
167
- * ```typescript
168
- * const store = state({
169
- * count: 0,
170
- * user: state({
171
- * name: 'Alice'
172
- * })
173
- * });
174
- *
175
- * store.count++; // Triggers reactivity
176
- *
177
- * const unsub = store.$subscribe('count', (val) => {
178
- * console.log('Count:', val);
179
- * });
180
- *
181
- * // Cleanup
182
- * unsub();
183
- * ```
184
- *
185
- */
186
- export function state<T extends object>(obj: T): ReactiveState<T>;
23
+ import type { ReactiveState, Unsubscribe } from './state.js';
187
24
 
188
25
  /**
189
26
  * Handler interface for extending bindDom with custom reactive data-* attributes.
@@ -353,29 +190,6 @@ export function effect(fn: () => void): Unsubscribe;
353
190
  */
354
191
  export function effect(fn: () => void, deps: EffectDependency[]): Unsubscribe;
355
192
 
356
- /**
357
- * Run a function with a read observer active.
358
- *
359
- * The observer receives `(proxy, key, registerEffect)` for every property read
360
- * during the synchronous execution of `fn`. Used internally by `effect()` for
361
- * auto-tracking, and exposed for building custom reactive primitives.
362
- *
363
- * @param onRead - Called on each property access inside fn
364
- * @param fn - The function to run under observation
365
- * @returns The return value of fn
366
- *
367
- * @internal
368
- */
369
- export function withReadObserver<T>(
370
- onRead: (
371
- proxy: ReactiveState<any>,
372
- key: string,
373
- registerEffect: (key: string, executeFn: () => void) => () => void
374
- ) => void,
375
- fn: () => T
376
- ): T;
377
-
378
-
379
193
  // ============================================================================
380
194
  // Utility Types
381
195
  // ============================================================================
package/src/index.js CHANGED
@@ -5,12 +5,14 @@
5
5
  * - state(): create reactive state
6
6
  * - bindDom(): zero-runtime DOM binding
7
7
  * - effect(): reactive effect with automatic dependency tracking
8
+ * - batch(): group writes across states, flush once synchronously
8
9
  * - withReadObserver(): advanced API for custom reactive primitives
9
10
  *
10
11
  * Usage:
11
- * import { state, bindDom, effect } from "lume-js";
12
+ * import { state, bindDom, effect, batch } from "lume-js";
12
13
  */
13
14
 
14
15
  export { state, withReadObserver } from "./core/state.js";
16
+ export { batch } from "./core/batch.js";
15
17
  export { bindDom } from "./core/bindDom.js";
16
18
  export { effect } from "./core/effect.js";