juststore 0.3.2 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -454,44 +454,46 @@ Creates a form store with validation support.
454
454
 
455
455
  The store root provides path-based methods for dynamic access:
456
456
 
457
- | Method | Description |
458
- | ---------------------------------------------- | ------------------------------------------------------- |
459
- | `.state(path)` | Get the state object for a path |
460
- | `.use(path)` | Subscribe and read value (triggers re-render on change) |
461
- | `.useDebounce(path, ms)` | Subscribe with debounced updates |
462
- | `.useState(path)` | Returns `[value, setValue]` tuple |
463
- | `.value(path)` | Read without subscribing |
464
- | `.set(path, value)` | Update value |
465
- | `.set(path, fn)` | Functional update |
466
- | `.reset(path)` | Delete value at path |
467
- | `.rename(path, oldKey, newKey, notifyObject?)` | Rename a key in an object |
468
- | `.subscribe(path, fn)` | Subscribe to changes (for effects) |
469
- | `.notify(path)` | Manually trigger subscribers |
470
- | `.useCompute(path, fn)` | Derive a computed value |
471
- | `.Render({ path, children })` | Render prop component |
472
- | `.Show({ path, children, on })` | Conditional render component |
457
+ | Method | Description |
458
+ | ------------------------------- | ------------------------------------------------------- |
459
+ | `.state(path)` | Get the state object for a path |
460
+ | `.use(path)` | Subscribe and read value (triggers re-render on change) |
461
+ | `.useDebounce(path, ms)` | Subscribe with debounced updates |
462
+ | `.useState(path)` | Returns `[value, setValue]` tuple |
463
+ | `.value(path)` | Read without subscribing |
464
+ | `.set(path, value)` | Update value |
465
+ | `.set(path, fn)` | Functional update |
466
+ | `.reset(path)` | Delete value at path |
467
+ | `.rename(path, oldKey, newKey)` | Rename a key in an object |
468
+ | `.keys(path)` | Get the readonly state of keys of an object |
469
+ | `.subscribe(path, fn)` | Subscribe to changes (for effects) |
470
+ | `.notify(path)` | Manually trigger subscribers |
471
+ | `.useCompute(path, fn)` | Derive a computed value |
472
+ | `.Render({ path, children })` | Render prop component |
473
+ | `.Show({ path, children, on })` | Conditional render component |
473
474
 
474
475
  ### State Methods
475
476
 
476
- | Method | Description |
477
- | ---------------------------------------- | ----------------------------------------------------------------------- |
478
- | `.use()` | Subscribe and read value (triggers re-render on change) |
479
- | `.useDebounce(ms)` | Subscribe with debounced updates |
480
- | `.useState()` | Returns `[value, setValue]` tuple |
481
- | `.value` | Read without subscribing |
482
- | `.set(value)` | Update value |
483
- | `.set(fn)` | Functional update |
484
- | `.reset()` | Delete value at path |
485
- | `.subscribe(fn)` | Subscribe to changes (for effects) |
486
- | `.rename(oldKey, newKey, notifyObject?)` | Rename a key in an object |
487
- | `.notify()` | Manually trigger subscribers |
488
- | `.useCompute(fn)` | Derive a computed value |
489
- | `.derived({ from, to })` | Create bidirectional transform |
490
- | `.ensureArray()` | Ensure the value is an array |
491
- | `.ensureObject()` | Ensure the value is an object |
492
- | `.withDefault(defaultValue)` | Return a new state with a default value, and make the type non-nullable |
493
- | `.Render({ children })` | Render prop component |
494
- | `.Show({ children, on })` | Conditional render component |
477
+ | Method | Description |
478
+ | ---------------------------- | ----------------------------------------------------------------------- |
479
+ | `.use()` | Subscribe and read value (triggers re-render on change) |
480
+ | `.useDebounce(ms)` | Subscribe with debounced updates |
481
+ | `.useState()` | Returns `[value, setValue]` tuple |
482
+ | `.value` | Read without subscribing |
483
+ | `.set(value)` | Update value |
484
+ | `.set(fn)` | Functional update |
485
+ | `.reset()` | Delete value at path |
486
+ | `.subscribe(fn)` | Subscribe to changes (for effects) |
487
+ | `.rename(oldKey, newKey)` | Rename a key in an object |
488
+ | `.keys()` | Get the readonly state of keys of an object |
489
+ | `.notify()` | Manually trigger subscribers |
490
+ | `.useCompute(fn)` | Derive a computed value |
491
+ | `.derived({ from, to })` | Create bidirectional transform |
492
+ | `.ensureArray()` | Ensure the value is an array |
493
+ | `.ensureObject()` | Ensure the value is an object |
494
+ | `.withDefault(defaultValue)` | Return a new state with a default value, and make the type non-nullable |
495
+ | `.Render({ children })` | Render prop component |
496
+ | `.Show({ children, on })` | Conditional render component |
495
497
 
496
498
  ## License
497
499
 
package/dist/impl.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import type { FieldPath, FieldPathValue, FieldValues } from './path';
2
- export { getNestedValue, getSnapshot, isClass, isEqual, joinPath, notifyListeners, produce, rename, setLeaf, subscribe, useDebounce, useObject, useSubscribe };
2
+ export { getNestedValue, getSnapshot, getStableKeys, isClass, isEqual, joinPath, notifyListeners, produce, rename, setExternalKeyOrder, setLeaf, subscribe, useDebounce, useObject, useSubscribe };
3
+ declare function setExternalKeyOrder(target: object, keys: string[]): void;
4
+ declare function getStableKeys(value: unknown): string[];
3
5
  declare function isClass(value: unknown): boolean;
4
6
  /** Compare two values for equality
5
7
  * @description
@@ -70,9 +72,8 @@ declare function produce(key: string, value: unknown, skipUpdate?: boolean, memo
70
72
  * @param path - The full key path to rename
71
73
  * @param oldKey - The old key to rename
72
74
  * @param newKey - The new key to rename to
73
- * @param notifyObject - Whether to notify listeners to the object path
74
75
  */
75
- declare function rename(path: string, oldKey: string, newKey: string, notifyObject?: boolean): void;
76
+ declare function rename(path: string, oldKey: string, newKey: string): void;
76
77
  /**
77
78
  * React hook that subscribes to and reads a value at a path.
78
79
  *
package/dist/impl.js CHANGED
@@ -1,10 +1,43 @@
1
1
  import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
2
2
  import rfcIsEqual from 'react-fast-compare';
3
3
  import { localStorageDelete, localStorageGet, localStorageSet } from './local_storage';
4
- export { getNestedValue, getSnapshot, isClass, isEqual, joinPath, notifyListeners, produce, rename, setLeaf, subscribe, useDebounce, useObject, useSubscribe };
4
+ export { getNestedValue, getSnapshot, getStableKeys, isClass, isEqual, joinPath, notifyListeners, produce, rename, setExternalKeyOrder, setLeaf, subscribe, useDebounce, useObject, useSubscribe };
5
5
  const memoryStore = new Map();
6
6
  const listeners = new Map();
7
7
  const descendantListenerKeysByPrefix = new Map();
8
+ const virtualRevisions = new Map();
9
+ const externalKeyOrder = new WeakMap();
10
+ function isVirtualKey(key) {
11
+ return key.endsWith('.__juststore_keys') || key === '__juststore_keys';
12
+ }
13
+ function setExternalKeyOrder(target, keys) {
14
+ externalKeyOrder.set(target, keys);
15
+ }
16
+ function hasOwn(target, key) {
17
+ return Object.prototype.hasOwnProperty.call(target, key);
18
+ }
19
+ function getStableKeys(value) {
20
+ if (!isRecord(value))
21
+ return [];
22
+ const target = value;
23
+ const existing = externalKeyOrder.get(target);
24
+ if (existing) {
25
+ const next = existing.filter(k => hasOwn(target, k));
26
+ const nextSet = new Set(next);
27
+ for (const k of Object.keys(target)) {
28
+ if (nextSet.has(k))
29
+ continue;
30
+ next.push(k);
31
+ }
32
+ if (next.length !== existing.length) {
33
+ setExternalKeyOrder(target, next);
34
+ }
35
+ return next;
36
+ }
37
+ const keys = Object.keys(target);
38
+ setExternalKeyOrder(target, keys);
39
+ return keys;
40
+ }
8
41
  // check if the value is a class instance
9
42
  function isClass(value) {
10
43
  if (value === null || value === undefined)
@@ -21,6 +54,13 @@ function isClass(value) {
21
54
  }
22
55
  return false;
23
56
  }
57
+ function isRecord(value) {
58
+ if (value === null || value === undefined)
59
+ return false;
60
+ if (typeof value !== 'object')
61
+ return false;
62
+ return !Array.isArray(value) && !isClass(value);
63
+ }
24
64
  /** Compare two values for equality
25
65
  * @description
26
66
  * - react-fast-compare for non-class instances
@@ -95,7 +135,14 @@ function setNestedValue(obj, path, value) {
95
135
  ? {}
96
136
  : Array.isArray(obj)
97
137
  ? [...obj]
98
- : { ...obj };
138
+ : (() => {
139
+ const existing = obj;
140
+ const next = { ...existing };
141
+ const order = externalKeyOrder.get(existing);
142
+ if (order)
143
+ setExternalKeyOrder(next, order);
144
+ return next;
145
+ })();
99
146
  let current = result;
100
147
  for (let i = 0; i < segments.length - 1; i++) {
101
148
  const segment = segments[i];
@@ -136,7 +183,11 @@ function setNestedValue(obj, path, value) {
136
183
  next = [...existing];
137
184
  }
138
185
  else {
139
- next = { ...existing };
186
+ const existingObj = existing;
187
+ next = { ...existingObj };
188
+ const order = externalKeyOrder.get(existingObj);
189
+ if (order)
190
+ setExternalKeyOrder(next, order);
140
191
  }
141
192
  currentObj[segment] = next;
142
193
  current = next;
@@ -156,11 +207,22 @@ function setNestedValue(obj, path, value) {
156
207
  }
157
208
  else if (typeof current === 'object' && current !== null) {
158
209
  const currentObj = current;
210
+ const hadKey = hasOwn(currentObj, lastSegment);
159
211
  if (value === undefined) {
160
212
  delete currentObj[lastSegment];
213
+ if (hadKey) {
214
+ const order = externalKeyOrder.get(currentObj);
215
+ if (order)
216
+ setExternalKeyOrder(currentObj, order.filter(k => k !== lastSegment));
217
+ }
161
218
  }
162
219
  else {
163
220
  currentObj[lastSegment] = value;
221
+ if (!hadKey) {
222
+ const order = externalKeyOrder.get(currentObj);
223
+ if (order)
224
+ setExternalKeyOrder(currentObj, [...order, lastSegment]);
225
+ }
164
226
  }
165
227
  }
166
228
  return result;
@@ -209,6 +271,9 @@ function getKeyPrefixes(key) {
209
271
  prefixes.unshift(parts[0]);
210
272
  return prefixes;
211
273
  }
274
+ function joinChildKey(parent, child) {
275
+ return parent ? `${parent}.${child}` : child;
276
+ }
212
277
  /**
213
278
  * Notifies all relevant listeners when a value changes.
214
279
  *
@@ -221,6 +286,23 @@ function getKeyPrefixes(key) {
221
286
  * determined by deep equality comparison.
222
287
  */
223
288
  function notifyListeners(key, oldValue, newValue, { skipRoot = false, skipChildren = false, forceNotify = false } = {}) {
289
+ // Keep `state.xxx.keys()` in sync: any mutation under a path can change the set of
290
+ // keys for that path (or its ancestors). Keys are represented as virtual nodes at
291
+ // `${path}.__juststore_keys`, so we bump those virtual nodes here.
292
+ //
293
+ // Important: avoid recursion when *we* are notifying a virtual key.
294
+ if (!isVirtualKey(key)) {
295
+ const paths = [...getKeyPrefixes(key), key];
296
+ for (const p of paths) {
297
+ const virtualKey = joinChildKey(p, '__juststore_keys');
298
+ const listenerSet = listeners.get(virtualKey);
299
+ if (listenerSet && listenerSet.size > 0) {
300
+ // Only notify the virtual key subscribers; the current call will handle
301
+ // ancestors/children for the real key.
302
+ notifyVirtualKey(virtualKey);
303
+ }
304
+ }
305
+ }
224
306
  if (skipRoot && skipChildren) {
225
307
  if (!forceNotify && isEqual(oldValue, newValue)) {
226
308
  return;
@@ -260,6 +342,21 @@ function notifyListeners(key, oldValue, newValue, { skipRoot = false, skipChildr
260
342
  const childKeys = descendantListenerKeysByPrefix.get(key);
261
343
  if (childKeys) {
262
344
  for (const childKey of childKeys) {
345
+ if (isVirtualKey(childKey)) {
346
+ const childPath = childKey.slice(key.length + 1);
347
+ const suffix = '.__juststore_keys';
348
+ const objectPath = childPath.endsWith(suffix) ? childPath.slice(0, -suffix.length) : '';
349
+ const getKeys = (root) => {
350
+ const obj = objectPath ? getNestedValue(root, objectPath) : root;
351
+ return getStableKeys(obj);
352
+ };
353
+ const oldKeys = getKeys(oldValue);
354
+ const newKeys = getKeys(newValue);
355
+ if (forceNotify || !isEqual(oldKeys, newKeys)) {
356
+ notifyVirtualKey(childKey);
357
+ }
358
+ continue;
359
+ }
263
360
  const childPath = childKey.slice(key.length + 1);
264
361
  const oldChildValue = getNestedValue(oldValue, childPath);
265
362
  const newChildValue = getNestedValue(newValue, childPath);
@@ -273,8 +370,13 @@ function notifyListeners(key, oldValue, newValue, { skipRoot = false, skipChildr
273
370
  }
274
371
  }
275
372
  }
276
- function forceNotifyListeners(key, options = {}) {
277
- notifyListeners(key, undefined, undefined, { ...options, forceNotify: true });
373
+ function notifyVirtualKey(key) {
374
+ virtualRevisions.set(key, (virtualRevisions.get(key) ?? 0) + 1);
375
+ notifyListeners(key, undefined, undefined, {
376
+ skipRoot: true,
377
+ skipChildren: true,
378
+ forceNotify: true
379
+ });
278
380
  }
279
381
  // BroadcastChannel for cross-tab synchronization
280
382
  const broadcastChannel = typeof window !== 'undefined' ? new BroadcastChannel('juststore') : null;
@@ -367,6 +469,9 @@ const store = {
367
469
  };
368
470
  /** Snapshot getter used by React's useSyncExternalStore. */
369
471
  function getSnapshot(key) {
472
+ if (isVirtualKey(key)) {
473
+ return virtualRevisions.get(key) ?? 0;
474
+ }
370
475
  return store.get(key);
371
476
  }
372
477
  // Cross-tab synchronization: keep memoryStore in sync with BroadcastChannel events
@@ -413,14 +518,14 @@ function subscribe(key, listener) {
413
518
  keyListeners.delete(listener);
414
519
  if (keyListeners.size === 0) {
415
520
  listeners.delete(key);
416
- }
417
- }
418
- for (const prefix of prefixes) {
419
- const prefixKeys = descendantListenerKeysByPrefix.get(prefix);
420
- if (prefixKeys) {
421
- prefixKeys.delete(key);
422
- if (prefixKeys.size === 0) {
423
- descendantListenerKeysByPrefix.delete(prefix);
521
+ for (const prefix of prefixes) {
522
+ const prefixKeys = descendantListenerKeysByPrefix.get(prefix);
523
+ if (prefixKeys) {
524
+ prefixKeys.delete(key);
525
+ if (prefixKeys.size === 0) {
526
+ descendantListenerKeysByPrefix.delete(prefix);
527
+ }
528
+ }
424
529
  }
425
530
  }
426
531
  }
@@ -459,29 +564,37 @@ function produce(key, value, skipUpdate = false, memoryOnly = false) {
459
564
  * @param path - The full key path to rename
460
565
  * @param oldKey - The old key to rename
461
566
  * @param newKey - The new key to rename to
462
- * @param notifyObject - Whether to notify listeners to the object path
463
567
  */
464
- function rename(path, oldKey, newKey, notifyObject = true) {
568
+ function rename(path, oldKey, newKey) {
465
569
  const current = store.get(path);
466
570
  if (current === undefined || current === null || typeof current !== 'object') {
467
571
  // assign a new object with the new key
468
- store.set(path, { [newKey]: undefined });
469
- if (notifyObject) {
470
- forceNotifyListeners(path, { skipChildren: true });
471
- }
572
+ const next = { [newKey]: undefined };
573
+ store.set(path, next);
574
+ setExternalKeyOrder(next, [newKey]);
575
+ notifyListeners(path, current, next);
472
576
  return;
473
577
  }
474
- const oldValue = current[oldKey];
475
- const newObject = { ...current, [oldKey]: undefined, [newKey]: oldValue };
476
- delete newObject[oldKey];
477
- store.set(path, newObject);
478
- if (oldValue !== undefined) {
479
- forceNotifyListeners(joinPath(path, oldKey));
480
- }
481
- forceNotifyListeners(joinPath(path, newKey));
482
- if (notifyObject) {
483
- forceNotifyListeners(path, { skipChildren: true });
578
+ const obj = current;
579
+ if (oldKey === newKey)
580
+ return;
581
+ if (!hasOwn(obj, oldKey))
582
+ return;
583
+ const keyOrder = getStableKeys(obj);
584
+ const entries = [];
585
+ for (const key of keyOrder) {
586
+ if (!hasOwn(obj, key))
587
+ continue;
588
+ if (key === oldKey) {
589
+ entries.push([newKey, obj[oldKey]]);
590
+ continue;
591
+ }
592
+ entries.push([key, obj[key]]);
484
593
  }
594
+ const newObject = Object.fromEntries(entries);
595
+ store.set(path, newObject);
596
+ setExternalKeyOrder(newObject, Array.from(new Set(entries.map(([k]) => k))));
597
+ notifyListeners(path, current, newObject);
485
598
  }
486
599
  /**
487
600
  * React hook that subscribes to and reads a value at a path.
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export type * from './form';
2
2
  export { useForm } from './form';
3
3
  export { useMemoryStore, type MemoryStore } from './memory';
4
- export { createMixedState, type MixedState } from './mixed_state';
4
+ export { createMixedState } from './mixed_state';
5
5
  export type * from './path';
6
6
  export { createStore, type Store } from './store';
7
7
  export type * from './types';
@@ -1,10 +1,5 @@
1
- import type { Prettify, ValueState } from './types';
2
- export { createMixedState, type MixedState };
3
- /**
4
- * A combined state that aggregates multiple independent states into a tuple.
5
- * Provides read-only access via `value`, `use`, `Render`, and `Show`.
6
- */
7
- type MixedState<T extends readonly unknown[]> = Prettify<Pick<ValueState<Readonly<Required<T>>>, 'value' | 'use' | 'Render' | 'Show'>>;
1
+ import type { ReadOnlyState, ValueState } from './types';
2
+ export { createMixedState };
8
3
  /**
9
4
  * Creates a mixed state that combines multiple states into a tuple.
10
5
  *
@@ -22,4 +17,4 @@ type MixedState<T extends readonly unknown[]> = Prettify<Pick<ValueState<Readonl
22
17
  */
23
18
  declare function createMixedState<T extends readonly unknown[]>(...states: {
24
19
  [K in keyof T]-?: ValueState<T[K]>;
25
- }): MixedState<T>;
20
+ }): ReadOnlyState<T>;
@@ -21,6 +21,9 @@ function createMixedState(...states) {
21
21
  return states.map(state => state.value);
22
22
  },
23
23
  use,
24
+ useCompute(fn) {
25
+ return states.map(state => state.useCompute(value => fn(value)));
26
+ },
24
27
  Render({ children }) {
25
28
  const value = use();
26
29
  return children(value);
package/dist/node.js CHANGED
@@ -1,3 +1,5 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { getStableKeys } from './impl';
1
3
  export { createNode, createRootNode };
2
4
  /**
3
5
  * Creates the root proxy node for dynamic path access.
@@ -40,60 +42,80 @@ function createNode(storeApi, path, cache, extensions, from = unchanged, to = un
40
42
  return fieldName;
41
43
  }
42
44
  if (prop === 'use') {
43
- return () => from(storeApi.use(path));
45
+ return (_target._use ??= () => from(storeApi.use(path)));
44
46
  }
45
47
  if (prop === 'useDebounce') {
46
- return (delay) => from(storeApi.useDebounce(path, delay));
48
+ return (_target._useDebounce ??= (delay) => from(storeApi.useDebounce(path, delay)));
47
49
  }
48
50
  if (prop === 'useState') {
49
- return () => {
51
+ return (_target._useState ??= () => {
50
52
  const value = storeApi.use(path);
51
53
  return [from(value), (next) => storeApi.set(path, to(next))];
52
- };
54
+ });
53
55
  }
54
56
  if (prop === 'value') {
55
57
  return from(storeApi.value(path));
56
58
  }
57
59
  if (prop === 'set') {
58
- return (value, skipUpdate) => storeApi.set(path, to(value), skipUpdate);
60
+ return (_target._set ??= (value, skipUpdate) => storeApi.set(path, to(value), skipUpdate));
59
61
  }
60
62
  if (prop === 'reset') {
61
- return () => storeApi.reset(path);
63
+ return (_target._reset ??= () => storeApi.reset(path));
62
64
  }
63
65
  if (prop === 'subscribe') {
64
- return (listener) => storeApi.subscribe(path, value => listener(to(value)));
66
+ return (_target._subscribe ??= (listener) => storeApi.subscribe(path, value => listener(to(value))));
65
67
  }
66
68
  if (prop === 'Render') {
67
- return ({ children }) => storeApi.Render({
69
+ return (_target._Render ??= ({ children }) => storeApi.Render({
68
70
  path,
69
71
  children: (value, update) => children(from(value), value => update(to(value)))
70
- });
72
+ }));
71
73
  }
72
74
  if (prop === 'Show') {
73
- return ({ children, on }) => storeApi.Show({ path, children, on: value => on(from(value)) });
75
+ return (_target._Show ??= ({ children, on }) => storeApi.Show({ path, children, on: value => on(from(value)) }));
74
76
  }
75
77
  if (prop === 'useCompute') {
76
- return (fn) => {
78
+ return (_target._useCompute ??= (fn) => {
77
79
  return storeApi.useCompute(path, value => fn(from(value)));
78
- };
80
+ });
79
81
  }
80
82
  if (prop === 'derived') {
81
83
  if (isDerived) {
82
84
  throw new Error(`Derived method cannot be called on a derived node: ${path}`);
83
85
  }
84
- return ({ from, to }) => createNode(storeApi, path, cache, extensions, from, to);
86
+ return (_target._derived ??= ({ from, to }) => createNode(storeApi, path, cache, extensions, from, to));
85
87
  }
86
88
  if (prop === 'notify') {
87
- return () => storeApi.notify(path);
89
+ return (_target._notify ??= () => storeApi.notify(path));
88
90
  }
89
91
  if (prop === 'ensureArray') {
90
- return () => createNode(storeApi, path, cache, extensions, value => ensureArray(value, from), unchanged);
92
+ return (_target._ensureArray ??= () => {
93
+ const cacheKey = `${path}.__juststore_ensureArray`;
94
+ if (!isDerived && cache.has(cacheKey)) {
95
+ return cache.get(cacheKey);
96
+ }
97
+ const node = createNode(storeApi, path, cache, extensions, value => ensureArray(value, from), unchanged);
98
+ if (!isDerived) {
99
+ cache.set(cacheKey, node);
100
+ }
101
+ return node;
102
+ });
91
103
  }
92
104
  if (prop === 'ensureObject') {
93
- return () => createNode(storeApi, path, cache, extensions, value => ensureObject(value, from), to);
105
+ return (_target._ensureObject ??= () => {
106
+ const cacheKey = `${path}.__juststore_ensureObject`;
107
+ if (!isDerived && cache.has(cacheKey)) {
108
+ return cache.get(cacheKey);
109
+ }
110
+ const node = createNode(storeApi, path, cache, extensions, value => ensureObject(value, from), to);
111
+ if (!isDerived) {
112
+ cache.set(cacheKey, node);
113
+ }
114
+ return node;
115
+ });
94
116
  }
95
117
  if (prop === 'withDefault') {
96
- return (defaultValue) => createNode(storeApi, path, cache, extensions, value => withDefault(value, defaultValue, from), to);
118
+ return (_target._withDefault ??= (defaultValue) => createNode(storeApi, path, cache, extensions, value => withDefault(value, defaultValue, from), to));
97
119
  }
98
120
  if (isObjectMethod(prop)) {
99
121
  const derivedValue = from(storeApi.value(path));
@@ -101,9 +123,18 @@ function createNode(storeApi, path, cache, extensions, from = unchanged, to = un
101
123
  throw new Error(`Expected object at path ${path}, got ${typeof derivedValue}`);
102
124
  }
103
125
  if (prop === 'rename') {
104
- return (oldKey, newKey, notifyObject) => {
105
- storeApi.rename(path, oldKey, newKey, notifyObject);
106
- };
126
+ return (_target._rename ??= (oldKey, newKey) => storeApi.rename(path, oldKey, newKey));
127
+ }
128
+ if (prop === 'keys') {
129
+ const cacheKey = `${path}.__juststore_keys`;
130
+ if (!isDerived && cache.has(cacheKey)) {
131
+ return cache.get(cacheKey);
132
+ }
133
+ const keysNode = createKeysNode(storeApi, path, value => ensureObject(value, from));
134
+ if (!isDerived) {
135
+ cache.set(cacheKey, keysNode);
136
+ }
137
+ return keysNode;
107
138
  }
108
139
  }
109
140
  if (isArrayMethod(prop)) {
@@ -113,91 +144,115 @@ function createNode(storeApi, path, cache, extensions, from = unchanged, to = un
113
144
  }
114
145
  const currentArray = derivedValue ? [...derivedValue] : [];
115
146
  if (prop === 'at') {
116
- return (index) => {
147
+ return (_target._at ??= (index) => {
117
148
  const nextPath = path ? `${path}.${index}` : String(index);
118
149
  return createNode(storeApi, nextPath, cache, extensions);
119
- };
150
+ });
120
151
  }
121
152
  if (prop === 'length') {
122
153
  return currentArray.length;
123
154
  }
124
155
  // Array mutation methods
125
156
  if (prop === 'push') {
126
- return (...items) => {
127
- const newArray = [...currentArray, ...items];
157
+ return (_target._push ??= (...items) => {
158
+ // We need to fetch the current array at call time, not bind time
159
+ const arr = from(storeApi.value(path)) ?? [];
160
+ const newArray = [...arr, ...items];
128
161
  storeApi.set(path, isDerived ? newArray.map(to) : newArray);
129
162
  return newArray.length;
130
- };
163
+ });
131
164
  }
132
165
  if (prop === 'pop') {
133
- return () => {
134
- if (currentArray.length === 0)
166
+ return (_target._pop ??= () => {
167
+ const arr = from(storeApi.value(path)) ?? [];
168
+ if (arr.length === 0)
135
169
  return undefined;
136
- const newArray = currentArray.slice(0, -1);
137
- const poppedItem = currentArray[currentArray.length - 1];
170
+ const newArray = arr.slice(0, -1);
171
+ const poppedItem = arr[arr.length - 1];
138
172
  storeApi.set(path, isDerived ? newArray.map(to) : newArray);
139
173
  return poppedItem;
140
- };
174
+ });
141
175
  }
142
176
  if (prop === 'shift') {
143
- return () => {
144
- if (currentArray.length === 0)
177
+ return (_target._shift ??= () => {
178
+ const arr = from(storeApi.value(path)) ?? [];
179
+ if (arr.length === 0)
145
180
  return undefined;
146
- const newArray = currentArray.slice(1);
147
- const shiftedItem = currentArray[0];
181
+ const newArray = arr.slice(1);
182
+ const shiftedItem = arr[0];
148
183
  storeApi.set(path, isDerived ? newArray.map(to) : newArray);
149
184
  return shiftedItem;
150
- };
185
+ });
151
186
  }
152
187
  if (prop === 'unshift') {
153
- return (...items) => {
154
- const newArray = [...items, ...currentArray];
188
+ return (_target._unshift ??= (...items) => {
189
+ const arr = from(storeApi.value(path)) ?? [];
190
+ const newArray = [...items, ...arr];
155
191
  storeApi.set(path, isDerived ? newArray.map(to) : newArray);
156
192
  return newArray.length;
157
- };
193
+ });
158
194
  }
159
195
  if (prop === 'splice') {
160
- return (start, deleteCount, ...items) => {
161
- const deletedItems = currentArray.splice(start, deleteCount ?? 0, ...items);
162
- storeApi.set(path, isDerived ? currentArray.map(to) : currentArray);
196
+ return (_target._splice ??= (start, deleteCount, ...items) => {
197
+ const arr = from(storeApi.value(path)) ?? [];
198
+ const newArray = [...arr];
199
+ const deletedItems = newArray.splice(start, deleteCount ?? 0, ...items);
200
+ storeApi.set(path, isDerived ? newArray.map(to) : newArray);
163
201
  return deletedItems;
164
- };
202
+ });
165
203
  }
166
204
  if (prop === 'reverse') {
167
- return () => {
168
- if (!Array.isArray(currentArray))
205
+ return (_target._reverse ??= () => {
206
+ const arr = from(storeApi.value(path));
207
+ if (!Array.isArray(arr))
169
208
  return [];
170
- currentArray.reverse();
171
- storeApi.set(path, isDerived ? currentArray.map(to) : currentArray);
172
- return currentArray;
173
- };
209
+ const newArray = [...arr];
210
+ newArray.reverse();
211
+ storeApi.set(path, isDerived ? newArray.map(to) : newArray);
212
+ return newArray;
213
+ });
174
214
  }
175
215
  if (prop === 'sort') {
176
- return (compareFn) => {
177
- currentArray.sort(compareFn);
178
- storeApi.set(path, isDerived ? currentArray.map(to) : currentArray);
179
- return currentArray;
180
- };
216
+ return (_target._sort ??= (compareFn) => {
217
+ const arr = from(storeApi.value(path));
218
+ if (!Array.isArray(arr))
219
+ return [];
220
+ const newArray = [...arr];
221
+ newArray.sort(compareFn);
222
+ storeApi.set(path, isDerived ? newArray.map(to) : newArray);
223
+ return newArray;
224
+ });
181
225
  }
182
226
  if (prop === 'fill') {
183
- return (value, start, end) => {
184
- currentArray.fill(value, start, end);
185
- storeApi.set(path, isDerived ? currentArray.map(to) : currentArray);
186
- return currentArray;
187
- };
227
+ return (_target._fill ??= (value, start, end) => {
228
+ const arr = from(storeApi.value(path));
229
+ if (!Array.isArray(arr))
230
+ return [];
231
+ const newArray = [...arr];
232
+ newArray.fill(value, start, end);
233
+ storeApi.set(path, isDerived ? newArray.map(to) : newArray);
234
+ return newArray;
235
+ });
188
236
  }
189
237
  if (prop === 'copyWithin') {
190
- return (target, start, end) => {
191
- currentArray.copyWithin(target, start, end);
192
- storeApi.set(path, isDerived ? currentArray.map(to) : currentArray);
193
- return currentArray;
194
- };
238
+ return (_target._copyWithin ??= (target, start, end) => {
239
+ const arr = from(storeApi.value(path));
240
+ if (!Array.isArray(arr))
241
+ return [];
242
+ const newArray = [...arr];
243
+ newArray.copyWithin(target, start, end);
244
+ storeApi.set(path, isDerived ? newArray.map(to) : newArray);
245
+ return newArray;
246
+ });
195
247
  }
196
248
  if (prop === 'sortedInsert') {
197
- return (cmp, ...items) => {
249
+ return (_target._sortedInsert ??= (cmp, ...items) => {
250
+ const arr = from(storeApi.value(path));
251
+ if (!Array.isArray(arr))
252
+ return [];
198
253
  if (typeof cmp !== 'function')
199
- return currentArray.length;
200
- const newArray = [...currentArray];
254
+ return arr.length;
255
+ const newArray = [...arr];
201
256
  for (const item of items) {
202
257
  let left = 0;
203
258
  let right = newArray.length;
@@ -216,7 +271,7 @@ function createNode(storeApi, path, cache, extensions, from = unchanged, to = un
216
271
  }
217
272
  storeApi.set(path, isDerived ? newArray.map(to) : newArray);
218
273
  return newArray.length;
219
- };
274
+ });
220
275
  }
221
276
  }
222
277
  if (extensions?.[prop]?.get) {
@@ -260,13 +315,44 @@ function isArrayMethod(prop) {
260
315
  prop === 'sortedInsert');
261
316
  }
262
317
  function isObjectMethod(prop) {
263
- return prop === 'rename';
318
+ return prop === 'rename' || prop === 'keys';
264
319
  }
265
320
  function unchanged(value) {
266
321
  return value;
267
322
  }
268
323
  const EMPTY_ARRAY = [];
269
324
  const EMPTY_OBJECT = {};
325
+ function createKeysNode(storeApi, path, getObjectValue) {
326
+ const signalPath = path ? `${path}.__juststore_keys` : '__juststore_keys';
327
+ const computeKeys = () => {
328
+ return getStableKeys(getObjectValue(storeApi.value(path)));
329
+ };
330
+ return new Proxy({}, {
331
+ get(_target, prop) {
332
+ if (prop === 'use') {
333
+ return (_target._use ??= () => storeApi.useCompute(signalPath, computeKeys));
334
+ }
335
+ if (prop === 'value')
336
+ return computeKeys();
337
+ if (prop === 'useCompute') {
338
+ return (_target._useCompute ??= (fn) => {
339
+ return storeApi.useCompute(signalPath, () => fn(computeKeys()));
340
+ });
341
+ }
342
+ if (prop === 'Render') {
343
+ return (_target._Render ??= ({ children }) => children(storeApi.useCompute(signalPath, computeKeys), () => { }));
344
+ }
345
+ if (prop === 'Show') {
346
+ // eslint-disable-next-line react/display-name
347
+ return (_target._Show ??= ({ children, on }) => {
348
+ const show = storeApi.useCompute(signalPath, () => on(computeKeys()), [on]);
349
+ return show ? children : null;
350
+ });
351
+ }
352
+ return undefined;
353
+ }
354
+ });
355
+ }
270
356
  function ensureArray(value, from) {
271
357
  if (value === undefined || value === null)
272
358
  return EMPTY_ARRAY;
@@ -279,7 +365,7 @@ function ensureObject(value, from) {
279
365
  if (value === undefined || value === null)
280
366
  return EMPTY_OBJECT;
281
367
  const obj = from(value);
282
- if (typeof obj === 'object')
368
+ if (obj && typeof obj === 'object' && !Array.isArray(obj))
283
369
  return obj;
284
370
  return EMPTY_OBJECT;
285
371
  }
package/dist/root.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useRef, useSyncExternalStore } from 'react';
2
- import { getNestedValue, getSnapshot, joinPath, notifyListeners, produce, rename, setLeaf, subscribe, useDebounce, useObject, useSubscribe } from './impl';
2
+ import { getNestedValue, getSnapshot, isEqual, joinPath, notifyListeners, produce, rename, setLeaf, subscribe, useDebounce, useObject, useSubscribe } from './impl';
3
3
  import { createRootNode } from './node';
4
4
  export { createStoreRoot };
5
5
  /**
@@ -33,28 +33,39 @@ function createStoreRoot(namespace, defaultValue, options = {}) {
33
33
  },
34
34
  value: (path) => getSnapshot(joinPath(namespace, path)),
35
35
  reset: (path) => produce(joinPath(namespace, path), undefined, false, memoryOnly),
36
- rename: (path, oldKey, newKey, notifyObject = true) => rename(joinPath(namespace, path), oldKey, newKey, notifyObject),
36
+ rename: (path, oldKey, newKey) => rename(joinPath(namespace, path), oldKey, newKey),
37
37
  subscribe: (path, listener) =>
38
38
  // eslint-disable-next-line react-hooks/rules-of-hooks
39
39
  useSubscribe(joinPath(namespace, path), listener),
40
- useCompute: (path, fn) => {
40
+ useCompute: (path, fn, deps) => {
41
41
  const fullPath = joinPath(namespace, path);
42
42
  const fnRef = useRef(fn);
43
43
  fnRef.current = fn;
44
- // Cache to avoid infinite loops - only recompute when store value changes
45
44
  const cacheRef = useRef(null);
46
45
  const subscribeToPath = useCallback((onStoreChange) => subscribe(fullPath, onStoreChange), [fullPath]);
47
46
  const getComputedSnapshot = useCallback(() => {
47
+ if (cacheRef.current && cacheRef.current.path !== fullPath) {
48
+ cacheRef.current = null;
49
+ }
50
+ if (cacheRef.current && !isEqual(cacheRef.current.deps, deps)) {
51
+ cacheRef.current = null;
52
+ }
48
53
  const storeValue = getSnapshot(fullPath);
49
- // Return cached result if store value hasn't changed
50
- if (cacheRef.current && cacheRef.current.storeValue === storeValue) {
54
+ if (cacheRef.current && isEqual(cacheRef.current.storeValue, storeValue)) {
55
+ // same store value, return the same computed value
56
+ return cacheRef.current.computed;
57
+ }
58
+ const computedNext = fnRef.current(storeValue);
59
+ // Important: even if storeValue changed, we should avoid forcing a re-render
60
+ // when the computed result is logically unchanged. `useSyncExternalStore`
61
+ // uses `Object.is` on the snapshot; returning the same reference will bail out.
62
+ if (cacheRef.current && isEqual(cacheRef.current.computed, computedNext)) {
63
+ cacheRef.current.storeValue = storeValue;
51
64
  return cacheRef.current.computed;
52
65
  }
53
- // Recompute and cache
54
- const computed = fnRef.current(storeValue);
55
- cacheRef.current = { storeValue, computed };
56
- return computed;
57
- }, [fullPath]);
66
+ cacheRef.current = { path: fullPath, storeValue, computed: computedNext, deps };
67
+ return computedNext;
68
+ }, [fullPath, deps]);
58
69
  return useSyncExternalStore(subscribeToPath, getComputedSnapshot, getComputedSnapshot);
59
70
  },
60
71
  notify: (path) => {
@@ -66,28 +77,28 @@ function createStoreRoot(namespace, defaultValue, options = {}) {
66
77
  });
67
78
  },
68
79
  useState: (path) => {
69
- const fullPathRef = useRef(joinPath(namespace, path));
80
+ const fullPath = joinPath(namespace, path);
70
81
  const setValue = useCallback((value) => {
71
82
  if (typeof value === 'function') {
72
- const currentValue = getSnapshot(fullPathRef.current);
83
+ const currentValue = getSnapshot(fullPath);
73
84
  const newValue = value(currentValue);
74
85
  return setLeaf(namespace, path, newValue, false, memoryOnly);
75
86
  }
76
87
  return setLeaf(namespace, path, value, false, memoryOnly);
77
- }, [path]);
78
- return [useObject(fullPathRef.current), setValue];
88
+ }, [fullPath, path]);
89
+ return [useObject(namespace, path), setValue];
79
90
  },
80
91
  Render: ({ path, children }) => {
81
- const fullPathRef = useRef(joinPath(namespace, path));
82
- const value = useObject(fullPathRef.current);
92
+ const fullPath = joinPath(namespace, path);
93
+ const value = useObject(namespace, path);
83
94
  const update = useCallback((value) => {
84
95
  if (typeof value === 'function') {
85
- const currentValue = getSnapshot(fullPathRef.current);
96
+ const currentValue = getSnapshot(fullPath);
86
97
  const newValue = value(currentValue);
87
98
  return setLeaf(namespace, path, newValue, false, memoryOnly);
88
99
  }
89
100
  return setLeaf(namespace, path, value, false, memoryOnly);
90
- }, [path]);
101
+ }, [fullPath, path]);
91
102
  return children(value, update);
92
103
  },
93
104
  Show: ({ path, children, on }) => {
package/dist/types.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { FieldPath, FieldPathValue, FieldValues, IsEqual } from './path';
2
- export type { AllowedKeys, ArrayProxy, ArrayState, DerivedStateProps, IsNullable, MaybeNullable, ObjectMutationMethods, ObjectState, Prettify, State, StoreRenderProps, StoreRoot, StoreSetStateAction, StoreShowProps, StoreUse, ValueState };
2
+ export type { AllowedKeys, ArrayProxy, ArrayState, DerivedStateProps, IsNullable, MaybeNullable, ObjectMutationMethods, ObjectState, Prettify, ReadOnlyState, State, StoreRenderProps, StoreRoot, StoreSetStateAction, StoreShowProps, StoreUse, ValueState };
3
3
  type Prettify<T> = {
4
4
  [K in keyof T]: T[K];
5
5
  } & {};
@@ -23,9 +23,18 @@ type ArrayProxy<T, ElementState = State<T>> = ArrayMutationMethods<T> & {
23
23
  /** Insert items into the array in sorted order using the provided comparison function. */
24
24
  sortedInsert(cmp: (a: T, b: T) => number, ...items: T[]): number;
25
25
  };
26
+ type ObjectProxy<T extends FieldValues> = {
27
+ /** Virtual state for the object's keys.
28
+ *
29
+ * This does NOT read from a real `keys` property on the stored object; it results in a stable array of keys.
30
+ */
31
+ readonly keys: ReadOnlyState<FieldPath<T>[]>;
32
+ } & {
33
+ [K in keyof T]-?: State<T[K]>;
34
+ };
26
35
  type ObjectMutationMethods = {
27
36
  /** Rename a key in an object. */
28
- rename: (oldKey: string, newKey: string, notifyObject?: boolean) => void;
37
+ rename: (oldKey: string, newKey: string) => void;
29
38
  };
30
39
  /** Tuple returned by Store.use(path). */
31
40
  type StoreUse<T> = Readonly<[T | undefined, (value: T | undefined) => void]>;
@@ -47,11 +56,11 @@ type StoreRoot<T extends FieldValues> = {
47
56
  /** Delete value at path (for arrays, removes index; for objects, deletes key). */
48
57
  reset: <P extends FieldPath<T>>(path: P) => void;
49
58
  /** Rename a key in an object. */
50
- rename: <P extends FieldPath<T>>(path: P, oldKey: string, newKey: string, notifyObject?: boolean) => void;
59
+ rename: <P extends FieldPath<T>>(path: P, oldKey: string, newKey: string) => void;
51
60
  /** Subscribe to changes at path and invoke listener with the new value. */
52
61
  subscribe: <P extends FieldPath<T>>(path: P, listener: (value: FieldPathValue<T, P>) => void) => void;
53
62
  /** Compute a derived value from the current value, similar to useState + useMemo */
54
- useCompute: <P extends FieldPath<T>, R>(path: P, fn: (value: FieldPathValue<T, P>) => R) => R;
63
+ useCompute: <P extends FieldPath<T>, R>(path: P, fn: (value: FieldPathValue<T, P>) => R, deps?: readonly unknown[]) => R;
55
64
  /** Notify listeners at path. */
56
65
  notify: <P extends FieldPath<T>>(path: P) => void;
57
66
  /** Render-prop helper for inline usage. */
@@ -78,7 +87,7 @@ type ValueState<T> = {
78
87
  /** Subscribe to changes at path and invoke listener with the new value. */
79
88
  subscribe(listener: (value: T) => void): void;
80
89
  /** Compute a derived value from the current value, similar to useState + useMemo */
81
- useCompute: <R>(fn: (value: T) => R) => R;
90
+ useCompute: <R>(fn: (value: T) => R, deps?: readonly unknown[]) => R;
82
91
  /** Ensure the value is an array. */
83
92
  ensureArray(): NonNullable<T> extends (infer U)[] ? ArrayState<U> : never;
84
93
  /** Ensure the value is an object. */
@@ -125,13 +134,15 @@ type ValueState<T> = {
125
134
  on: (value: T) => boolean;
126
135
  }) => React.ReactNode;
127
136
  };
137
+ /**
138
+ * A read-only state that provides access to the value, use, Render, and Show methods.
139
+ */
140
+ type ReadOnlyState<T> = Prettify<Pick<ValueState<Readonly<Required<T>>>, 'value' | 'use' | 'useCompute' | 'Render' | 'Show'>>;
128
141
  type MaybeNullable<T, Nullable extends boolean = false> = Nullable extends true ? T | undefined : T;
129
142
  type IsNullable<T> = T extends undefined | null ? true : false;
130
143
  type State<T> = IsEqual<T, unknown> extends true ? never : [NonNullable<T>] extends [readonly (infer U)[]] ? ArrayState<U, IsNullable<T>> : [NonNullable<T>] extends [FieldValues] ? ObjectState<NonNullable<T>, IsNullable<T>> : ValueState<T>;
131
144
  type ArrayState<T, Nullable extends boolean = false> = IsEqual<T, unknown> extends true ? never : ValueState<MaybeNullable<T[], Nullable>> & ArrayProxy<T>;
132
- type ObjectState<T extends FieldValues, Nullable extends boolean = false> = {
133
- [K in keyof T]-?: State<T[K]>;
134
- } & ValueState<MaybeNullable<T, Nullable>> & ObjectMutationMethods;
145
+ type ObjectState<T extends FieldValues, Nullable extends boolean = false> = ObjectProxy<T> & ValueState<MaybeNullable<T, Nullable>> & ObjectMutationMethods;
135
146
  /** Props for Store.Render helper. */
136
147
  type StoreRenderProps<T extends FieldValues, P extends FieldPath<T>> = {
137
148
  path: P;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "juststore",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "A small, expressive, and type-safe state management library for React.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -52,19 +52,19 @@
52
52
  "react-fast-compare": "^3.2.2"
53
53
  },
54
54
  "devDependencies": {
55
- "@eslint/js": "^9.39.1",
56
- "@types/node": "^24.10.2",
57
- "@types/react": "^19.2.1",
58
- "@typescript-eslint/eslint-plugin": "^8.49.0",
59
- "@typescript-eslint/parser": "^8.49.0",
60
- "eslint": "^9.39.1",
55
+ "@eslint/js": "^9.39.2",
56
+ "@types/node": "^24.10.4",
57
+ "@types/react": "^19.2.7",
58
+ "@typescript-eslint/eslint-plugin": "^8.50.1",
59
+ "@typescript-eslint/parser": "^8.50.1",
60
+ "eslint": "^9.39.2",
61
61
  "eslint-plugin-prettier": "^5.5.4",
62
62
  "eslint-plugin-react": "^7.37.5",
63
63
  "eslint-plugin-react-hooks": "^7.0.1",
64
- "eslint-plugin-react-refresh": "^0.4.24",
64
+ "eslint-plugin-react-refresh": "^0.4.26",
65
65
  "husky": "^9.1.7",
66
66
  "prettier": "^3.7.4",
67
- "react": "^19.2.1",
67
+ "react": "^19.2.3",
68
68
  "typescript": "^5.9.3"
69
69
  }
70
70
  }