juststore 0.1.4 → 0.3.2

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,38 +454,44 @@ 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
- | `.use(path)` | Subscribe and read value (triggers re-render on change) |
460
- | `.useDebounce(path, ms)` | Subscribe with debounced updates |
461
- | `.useState(path)` | Returns `[value, setValue]` tuple |
462
- | `.value(path)` | Read without subscribing |
463
- | `.set(path, value)` | Update value |
464
- | `.set(path, fn)` | Functional update |
465
- | `.reset(path)` | Delete value at path |
466
- | `.subscribe(path, fn)` | Subscribe to changes (for effects) |
467
- | `.notify(path)` | Manually trigger subscribers |
468
- | `.useCompute(path, fn)` | Derive a computed value |
469
- | `.Render({ path, children })` | Render prop component |
470
- | `.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, 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 |
471
473
 
472
474
  ### State Methods
473
475
 
474
- | Method | Description |
475
- | ------------------------- | ------------------------------------------------------- |
476
- | `.use()` | Subscribe and read value (triggers re-render on change) |
477
- | `.useDebounce(ms)` | Subscribe with debounced updates |
478
- | `.useState()` | Returns `[value, setValue]` tuple |
479
- | `.value` | Read without subscribing |
480
- | `.set(value)` | Update value |
481
- | `.set(fn)` | Functional update |
482
- | `.reset()` | Delete value at path |
483
- | `.subscribe(fn)` | Subscribe to changes (for effects) |
484
- | `.notify()` | Manually trigger subscribers |
485
- | `.useCompute(fn)` | Derive a computed value |
486
- | `.derived({ from, to })` | Create bidirectional transform |
487
- | `.Render({ children })` | Render prop component |
488
- | `.Show({ children, on })` | Conditional render component |
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 |
489
495
 
490
496
  ## License
491
497
 
package/dist/form.d.ts CHANGED
@@ -1,6 +1,6 @@
1
- import type { FieldPath, FieldPathValue, FieldValues } from './path';
2
- import type { ArrayProxy, State } from './types';
3
- export { useForm, type CreateFormOptions, type DeepNonNullable, type FormArrayProxy, type FormDeepProxy, type FormState, type FormStore };
1
+ import type { FieldPath, FieldPathValue, FieldValues, IsEqual } from './path';
2
+ import type { ArrayProxy, IsNullable, MaybeNullable, ObjectMutationMethods, ValueState } from './types';
3
+ export { useForm, type CreateFormOptions, type DeepNonNullable, type FormArrayState, type FormObjectState, type FormState, type FormStore, type FormValueState };
4
4
  /**
5
5
  * Common form field methods available on every form state node.
6
6
  */
@@ -12,20 +12,39 @@ type FormCommon = {
12
12
  /** Manually set a validation error. */
13
13
  setError: (error: string | undefined) => void;
14
14
  };
15
- type FormArrayProxy<T> = ArrayProxy<T> & FormCommon;
16
- type FormState<T> = State<T> & FormCommon;
15
+ type FormState<T> = IsEqual<T, unknown> extends true ? never : [NonNullable<T>] extends [readonly (infer U)[]] ? FormArrayState<U, IsNullable<T>> : [NonNullable<T>] extends [FieldValues] ? FormObjectState<NonNullable<T>, IsNullable<T>> : FormValueState<T>;
16
+ interface FormValueState<T> extends Omit<ValueState<T>, 'withDefault' | 'derived'>, FormCommon {
17
+ /** Return a new state with a default value, and make the type non-nullable */
18
+ withDefault(defaultValue: T): FormState<NonNullable<T>>;
19
+ /** Virtual state derived from the current value.
20
+ *
21
+ * @returns ArrayState if the derived value is an array, ObjectState if the derived value is an object, otherwise State.
22
+ * @example
23
+ * const state = store.a.b.c.derived({
24
+ * from: value => value + 1,
25
+ * to: value => value - 1
26
+ * })
27
+ * state.use() // returns the derived value
28
+ * state.set(10) // sets the derived value
29
+ * state.reset() // resets the derived value
30
+ */
31
+ derived: <R>({ from, to }: {
32
+ from?: (value: T | undefined) => R;
33
+ to?: (value: R) => T | undefined;
34
+ }) => FormState<R>;
35
+ }
36
+ type FormArrayState<T, Nullable extends boolean = false, TT = MaybeNullable<T[], Nullable>> = IsEqual<T, unknown> extends true ? never : FormValueState<TT[]> & ArrayProxy<TT, FormState<TT>>;
37
+ type FormObjectState<T extends FieldValues, Nullable extends boolean = false> = {
38
+ [K in keyof T]-?: FormState<T[K]>;
39
+ } & FormValueState<MaybeNullable<T, Nullable>> & ObjectMutationMethods;
17
40
  /** Type for nested objects with proxy methods */
18
- type FormDeepProxy<T> = NonNullable<T> extends readonly (infer U)[] ? FormArrayProxy<U> & FormState<T> : NonNullable<T> extends FieldValues ? {
19
- [K in keyof NonNullable<T>]-?: NonNullable<NonNullable<T>[K]> extends object ? FormDeepProxy<NonNullable<T>[K]> : FormState<NonNullable<T>[K]>;
20
- } & FormState<T> : FormState<T>;
21
- /** Type for nested objects with proxy methods */
22
- type DeepNonNullable<T> = NonNullable<T> extends readonly (infer U)[] ? U[] : NonNullable<T> extends FieldValues ? {
41
+ type DeepNonNullable<T> = [NonNullable<T>] extends [readonly (infer U)[]] ? U[] : [NonNullable<T>] extends [FieldValues] ? {
23
42
  [K in keyof NonNullable<T>]-?: DeepNonNullable<NonNullable<T>[K]>;
24
43
  } : NonNullable<T>;
25
44
  /**
26
45
  * The form store type, combining form state with validation and submission handling.
27
46
  */
28
- type FormStore<T extends FieldValues> = FormDeepProxy<T> & {
47
+ type FormStore<T extends FieldValues> = FormState<T> & {
29
48
  /** Clears all validation errors from the form. */
30
49
  clearErrors(): void;
31
50
  /** Returns a form submit handler that validates and calls onSubmit with form values. */
package/dist/impl.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { FieldPath, FieldPathValue, FieldValues } from './path';
2
- export { getNestedValue, getSnapshot, isClass, isEqual, joinPath, notifyListeners, produce, setLeaf, useDebounce, useObject, useSubscribe };
2
+ export { getNestedValue, getSnapshot, isClass, isEqual, joinPath, notifyListeners, produce, rename, setLeaf, subscribe, useDebounce, useObject, useSubscribe };
3
3
  declare function isClass(value: unknown): boolean;
4
4
  /** Compare two values for equality
5
5
  * @description
@@ -31,9 +31,21 @@ declare function getNestedValue(obj: unknown, path: string): unknown;
31
31
  * Child listeners are only notified if their specific value actually changed,
32
32
  * determined by deep equality comparison.
33
33
  */
34
- declare function notifyListeners(key: string, oldValue: unknown, newValue: unknown, skipRoot?: boolean, skipChildren?: boolean): void;
34
+ declare function notifyListeners(key: string, oldValue: unknown, newValue: unknown, { skipRoot, skipChildren, forceNotify }?: {
35
+ skipRoot?: boolean | undefined;
36
+ skipChildren?: boolean | undefined;
37
+ forceNotify?: boolean | undefined;
38
+ }): void;
35
39
  /** Snapshot getter used by React's useSyncExternalStore. */
36
40
  declare function getSnapshot(key: string): unknown;
41
+ /**
42
+ * Subscribes to changes for a specific key.
43
+ *
44
+ * @param key - The full key path to subscribe to
45
+ * @param listener - Callback invoked when the value changes
46
+ * @returns An unsubscribe function to remove the listener
47
+ */
48
+ declare function subscribe(key: string, listener: () => void): () => void;
37
49
  /**
38
50
  * Core mutation function that updates the store and notifies listeners.
39
51
  *
@@ -46,6 +58,21 @@ declare function getSnapshot(key: string): unknown;
46
58
  * @param memoryOnly - When true, skips localStorage persistence
47
59
  */
48
60
  declare function produce(key: string, value: unknown, skipUpdate?: boolean, memoryOnly?: boolean): void;
61
+ /**
62
+ * Renames a key in an object.
63
+ *
64
+ * It trigger updates to
65
+ *
66
+ * - listeners to `path` (key is updated)
67
+ * - listeners to `path.oldKey` (deleted)
68
+ * - listeners to `path.newKey` (created)
69
+ *
70
+ * @param path - The full key path to rename
71
+ * @param oldKey - The old key to rename
72
+ * @param newKey - The new key to rename to
73
+ * @param notifyObject - Whether to notify listeners to the object path
74
+ */
75
+ declare function rename(path: string, oldKey: string, newKey: string, notifyObject?: boolean): void;
49
76
  /**
50
77
  * React hook that subscribes to and reads a value at a path.
51
78
  *
package/dist/impl.js CHANGED
@@ -1,8 +1,10 @@
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, setLeaf, useDebounce, useObject, useSubscribe };
4
+ export { getNestedValue, getSnapshot, isClass, isEqual, joinPath, notifyListeners, produce, rename, setLeaf, subscribe, useDebounce, useObject, useSubscribe };
5
5
  const memoryStore = new Map();
6
+ const listeners = new Map();
7
+ const descendantListenerKeysByPrefix = new Map();
6
8
  // check if the value is a class instance
7
9
  function isClass(value) {
8
10
  if (value === null || value === undefined)
@@ -14,7 +16,7 @@ function isClass(value) {
14
16
  return false;
15
17
  const descriptors = Object.getOwnPropertyDescriptors(proto);
16
18
  for (const key in descriptors) {
17
- if (descriptors[key].get)
19
+ if (descriptors[key]?.get)
18
20
  return true;
19
21
  }
20
22
  return false;
@@ -28,6 +30,8 @@ function isClass(value) {
28
30
  * @returns True if the values are equal, false otherwise
29
31
  */
30
32
  function isEqual(a, b) {
33
+ if (a === b)
34
+ return true;
31
35
  if (isClass(a) || isClass(b))
32
36
  return a === b;
33
37
  return rfcIsEqual(a, b);
@@ -68,34 +72,6 @@ function getNestedValue(obj, path) {
68
72
  }
69
73
  return current;
70
74
  }
71
- /**
72
- * Creates a deep clone of an object, optimized for common cases.
73
- *
74
- * Uses fast paths for primitives and arrays of primitives, falling back
75
- * to structuredClone for complex objects. Returns null if cloning fails.
76
- *
77
- * @param obj - The value to clone
78
- * @returns A deep copy of the value, or null if cloning fails
79
- */
80
- function tryStructuredClone(obj) {
81
- if (obj === null || obj === undefined)
82
- return null;
83
- if (typeof obj !== 'object')
84
- return obj;
85
- if (Array.isArray(obj)) {
86
- const needsDeepClone = obj.some(item => item !== null && typeof item === 'object');
87
- if (!needsDeepClone) {
88
- return [...obj];
89
- }
90
- return obj.map(item => tryStructuredClone(item));
91
- }
92
- try {
93
- return structuredClone(obj);
94
- }
95
- catch {
96
- return null;
97
- }
98
- }
99
75
  /**
100
76
  * Immutably sets or deletes a nested value using a dot-separated path.
101
77
  *
@@ -112,8 +88,15 @@ function setNestedValue(obj, path, value) {
112
88
  if (!path)
113
89
  return value;
114
90
  const segments = path.split('.');
115
- const result = segments.length === 1 ? obj : tryStructuredClone(obj);
116
- let current = result ?? {};
91
+ if (obj !== null && obj !== undefined && typeof obj !== 'object') {
92
+ return obj;
93
+ }
94
+ const result = obj === null || obj === undefined
95
+ ? {}
96
+ : Array.isArray(obj)
97
+ ? [...obj]
98
+ : { ...obj };
99
+ let current = result;
117
100
  for (let i = 0; i < segments.length - 1; i++) {
118
101
  const segment = segments[i];
119
102
  const nextSegment = segments[i + 1];
@@ -122,17 +105,41 @@ function setNestedValue(obj, path, value) {
122
105
  const index = Number(segment);
123
106
  if (Number.isNaN(index))
124
107
  break;
125
- if (!current[index]) {
126
- current[index] = isNextIndex ? [] : {};
108
+ const existing = current[index];
109
+ let next;
110
+ if (existing === null || existing === undefined) {
111
+ next = isNextIndex ? [] : {};
127
112
  }
128
- current = current[index];
113
+ else if (typeof existing !== 'object') {
114
+ next = isNextIndex ? [] : {};
115
+ }
116
+ else if (Array.isArray(existing)) {
117
+ next = [...existing];
118
+ }
119
+ else {
120
+ next = { ...existing };
121
+ }
122
+ current[index] = next;
123
+ current = next;
129
124
  }
130
125
  else if (typeof current === 'object' && current !== null) {
131
126
  const currentObj = current;
132
- if (!currentObj[segment]) {
133
- currentObj[segment] = isNextIndex ? [] : {};
127
+ const existing = currentObj[segment];
128
+ let next;
129
+ if (existing === null || existing === undefined) {
130
+ next = isNextIndex ? [] : {};
131
+ }
132
+ else if (typeof existing !== 'object') {
133
+ next = isNextIndex ? [] : {};
134
+ }
135
+ else if (Array.isArray(existing)) {
136
+ next = [...existing];
137
+ }
138
+ else {
139
+ next = { ...existing };
134
140
  }
135
- current = currentObj[segment];
141
+ currentObj[segment] = next;
142
+ current = next;
136
143
  }
137
144
  }
138
145
  const lastSegment = segments[segments.length - 1];
@@ -161,21 +168,46 @@ function setNestedValue(obj, path, value) {
161
168
  /**
162
169
  * Extracts the root namespace from a full key.
163
170
  *
164
- * @param key - Full key string (e.g., "app.user.name")
165
- * @returns The first segment (e.g., "app")
171
+ * @param key - Full key string
172
+ * @returns Namespace
173
+ * @example
174
+ * getNamespace('app.user.name') // 'app'
166
175
  */
167
- function getRootKey(key) {
168
- return key.split('.')[0];
176
+ function getNamespace(key) {
177
+ const index = key.indexOf('.');
178
+ if (index === -1)
179
+ return key;
180
+ return key.slice(0, index);
169
181
  }
170
182
  /**
171
- * Extracts the nested path from a full key, excluding the namespace.
183
+ * Extracts the namespace and path from a full key.
172
184
  *
173
- * @param key - Full key string (e.g., "app.user.name")
174
- * @returns The path after the namespace (e.g., "user.name")
185
+ * @param key - Full key string
186
+ * @returns [namespace, path]
187
+ * @example
188
+ * splitNSPath('app.user.name') // ['app', 'user.name']
175
189
  */
176
- function getPath(key) {
177
- const segments = key.split('.');
178
- return segments.slice(1).join('.');
190
+ function splitNSPath(key) {
191
+ const index = key.indexOf('.');
192
+ if (index === -1)
193
+ return [key, ''];
194
+ return [key.slice(0, index), key.slice(index + 1)];
195
+ }
196
+ function getKeyPrefixes(key) {
197
+ const dot = key.indexOf('.');
198
+ if (dot === -1)
199
+ return [];
200
+ const parts = key.split('.');
201
+ if (parts.length <= 1)
202
+ return [];
203
+ const prefixes = [];
204
+ let current = parts[0];
205
+ for (let i = 1; i < parts.length - 1; i++) {
206
+ current += '.' + parts[i];
207
+ prefixes.push(current);
208
+ }
209
+ prefixes.unshift(parts[0]);
210
+ return prefixes;
179
211
  }
180
212
  /**
181
213
  * Notifies all relevant listeners when a value changes.
@@ -188,35 +220,64 @@ function getPath(key) {
188
220
  * Child listeners are only notified if their specific value actually changed,
189
221
  * determined by deep equality comparison.
190
222
  */
191
- function notifyListeners(key, oldValue, newValue, skipRoot = false, skipChildren = false) {
192
- const rootKey = skipRoot ? null : key.split('.').slice(0, 2).join('.');
193
- const keyPrefix = skipChildren ? null : key + '.';
194
- // Single pass: collect listeners to notify
195
- const listenersToNotify = new Set();
196
- for (const [listenerKey, listenerSet] of listeners.entries()) {
197
- if (listenerKey === key) {
198
- // Exact key match
199
- listenerSet.forEach(listener => listenersToNotify.add(listener));
223
+ function notifyListeners(key, oldValue, newValue, { skipRoot = false, skipChildren = false, forceNotify = false } = {}) {
224
+ if (skipRoot && skipChildren) {
225
+ if (!forceNotify && isEqual(oldValue, newValue)) {
226
+ return;
200
227
  }
201
- else if (rootKey && listenerKey === rootKey) {
202
- // Root key match
203
- listenerSet.forEach(listener => listenersToNotify.add(listener));
228
+ // exact match only
229
+ const listenerSet = listeners.get(key);
230
+ if (listenerSet) {
231
+ listenerSet.forEach(listener => listener());
204
232
  }
205
- else if (keyPrefix && listenerKey.startsWith(keyPrefix)) {
206
- // Child key match - check if value actually changed
207
- const childPath = listenerKey.substring(key.length + 1);
208
- const oldChildValue = getNestedValue(oldValue, childPath);
209
- const newChildValue = getNestedValue(newValue, childPath);
210
- if (!isEqual(oldChildValue, newChildValue)) {
211
- listenerSet.forEach(listener => listenersToNotify.add(listener));
233
+ return;
234
+ }
235
+ // Exact key match
236
+ const exactSet = listeners.get(key);
237
+ if (exactSet) {
238
+ exactSet.forEach(listener => listener());
239
+ }
240
+ // Ancestor keys match (including namespace root)
241
+ if (!skipRoot) {
242
+ const namespace = getNamespace(key);
243
+ const rootSet = listeners.get(namespace);
244
+ if (rootSet) {
245
+ rootSet.forEach(listener => listener());
246
+ }
247
+ // Also notify intermediate ancestors
248
+ const prefixes = getKeyPrefixes(key);
249
+ for (const prefix of prefixes) {
250
+ if (prefix === namespace)
251
+ continue; // Already handled
252
+ const prefixSet = listeners.get(prefix);
253
+ if (prefixSet) {
254
+ prefixSet.forEach(listener => listener());
255
+ }
256
+ }
257
+ }
258
+ // Child key match - check if value actually changed
259
+ if (!skipChildren) {
260
+ const childKeys = descendantListenerKeysByPrefix.get(key);
261
+ if (childKeys) {
262
+ for (const childKey of childKeys) {
263
+ const childPath = childKey.slice(key.length + 1);
264
+ const oldChildValue = getNestedValue(oldValue, childPath);
265
+ const newChildValue = getNestedValue(newValue, childPath);
266
+ if (forceNotify || !isEqual(oldChildValue, newChildValue)) {
267
+ const childSet = listeners.get(childKey);
268
+ if (childSet) {
269
+ childSet.forEach(listener => listener());
270
+ }
271
+ }
212
272
  }
213
273
  }
214
274
  }
215
- // Notify all collected listeners
216
- listenersToNotify.forEach(listener => listener());
275
+ }
276
+ function forceNotifyListeners(key, options = {}) {
277
+ notifyListeners(key, undefined, undefined, { ...options, forceNotify: true });
217
278
  }
218
279
  // BroadcastChannel for cross-tab synchronization
219
- const broadcastChannel = typeof window !== 'undefined' ? new BroadcastChannel('godoxy-producer-consumer') : null;
280
+ const broadcastChannel = typeof window !== 'undefined' ? new BroadcastChannel('juststore') : null;
220
281
  /**
221
282
  * Backing store providing in-memory data with localStorage persistence
222
283
  * and cross-tab synchronization. All operations are namespaced at the root key
@@ -224,13 +285,12 @@ const broadcastChannel = typeof window !== 'undefined' ? new BroadcastChannel('g
224
285
  */
225
286
  const store = {
226
287
  has(key) {
227
- const rootKey = getRootKey(key);
288
+ const rootKey = getNamespace(key);
228
289
  return (memoryStore.has(rootKey) ||
229
290
  (typeof window !== 'undefined' && localStorageGet(rootKey) !== undefined));
230
291
  },
231
292
  get(key) {
232
- const rootKey = getRootKey(key);
233
- const path = getPath(key);
293
+ const [rootKey, path] = splitNSPath(key);
234
294
  // Get root object from memory or localStorage
235
295
  let rootValue;
236
296
  if (memoryStore.has(rootKey)) {
@@ -249,8 +309,10 @@ const store = {
249
309
  return getNestedValue(rootValue, path);
250
310
  },
251
311
  set(key, value, memoryOnly = false) {
252
- const rootKey = getRootKey(key);
253
- const path = getPath(key);
312
+ if (value === undefined) {
313
+ return this.delete(key, memoryOnly);
314
+ }
315
+ const [rootKey, path] = splitNSPath(key);
254
316
  let rootValue;
255
317
  if (!path) {
256
318
  // Setting root value directly
@@ -262,12 +324,7 @@ const store = {
262
324
  rootValue = setNestedValue(currentRoot, path, value);
263
325
  }
264
326
  // Update memory
265
- if (rootValue === undefined) {
266
- memoryStore.delete(rootKey);
267
- }
268
- else {
269
- memoryStore.set(rootKey, rootValue);
270
- }
327
+ memoryStore.set(rootKey, rootValue);
271
328
  // Persist to localStorage (unless memoryOnly)
272
329
  if (!memoryOnly && typeof window !== 'undefined') {
273
330
  localStorageSet(rootKey, rootValue);
@@ -278,8 +335,7 @@ const store = {
278
335
  }
279
336
  },
280
337
  delete(key, memoryOnly = false) {
281
- const rootKey = getRootKey(key);
282
- const path = getPath(key);
338
+ const [rootKey, path] = splitNSPath(key);
283
339
  if (!path) {
284
340
  // Deleting root key
285
341
  memoryStore.delete(rootKey);
@@ -329,25 +385,9 @@ if (broadcastChannel) {
329
385
  }
330
386
  // Notify all listeners that might be affected by this root key change
331
387
  const newRootValue = type === 'delete' ? undefined : value;
332
- for (const listenerKey of listeners.keys()) {
333
- if (listenerKey === key) {
334
- // Direct key match - notify with old and new values
335
- notifyListeners(listenerKey, oldRootValue, newRootValue);
336
- }
337
- else if (listenerKey.startsWith(key + '.')) {
338
- // Child key - check if its value actually changed
339
- const childPath = listenerKey.substring(key.length + 1);
340
- const oldChildValue = getNestedValue(oldRootValue, childPath);
341
- const newChildValue = getNestedValue(newRootValue, childPath);
342
- if (!isEqual(oldChildValue, newChildValue)) {
343
- const childListeners = listeners.get(listenerKey);
344
- childListeners?.forEach(listener => listener());
345
- }
346
- }
347
- }
388
+ notifyListeners(key, oldRootValue, newRootValue);
348
389
  });
349
390
  }
350
- const listeners = new Map();
351
391
  /**
352
392
  * Subscribes to changes for a specific key.
353
393
  *
@@ -360,6 +400,13 @@ function subscribe(key, listener) {
360
400
  listeners.set(key, new Set());
361
401
  }
362
402
  listeners.get(key).add(listener);
403
+ const prefixes = getKeyPrefixes(key);
404
+ for (const prefix of prefixes) {
405
+ if (!descendantListenerKeysByPrefix.has(prefix)) {
406
+ descendantListenerKeysByPrefix.set(prefix, new Set());
407
+ }
408
+ descendantListenerKeysByPrefix.get(prefix).add(key);
409
+ }
363
410
  return () => {
364
411
  const keyListeners = listeners.get(key);
365
412
  if (keyListeners) {
@@ -368,6 +415,15 @@ function subscribe(key, listener) {
368
415
  listeners.delete(key);
369
416
  }
370
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);
424
+ }
425
+ }
426
+ }
371
427
  };
372
428
  }
373
429
  /**
@@ -383,20 +439,50 @@ function subscribe(key, listener) {
383
439
  */
384
440
  function produce(key, value, skipUpdate = false, memoryOnly = false) {
385
441
  const current = store.get(key);
386
- if (value === undefined) {
387
- skipUpdate = current === undefined;
388
- store.delete(key, memoryOnly);
389
- }
390
- else {
391
- if (isEqual(current, value))
392
- return;
393
- store.set(key, value, memoryOnly);
394
- }
442
+ if (isEqual(current, value))
443
+ return;
444
+ store.set(key, value, memoryOnly);
395
445
  if (skipUpdate)
396
446
  return;
397
447
  // Notify listeners hierarchically with old and new values
398
448
  notifyListeners(key, current, value);
399
449
  }
450
+ /**
451
+ * Renames a key in an object.
452
+ *
453
+ * It trigger updates to
454
+ *
455
+ * - listeners to `path` (key is updated)
456
+ * - listeners to `path.oldKey` (deleted)
457
+ * - listeners to `path.newKey` (created)
458
+ *
459
+ * @param path - The full key path to rename
460
+ * @param oldKey - The old key to rename
461
+ * @param newKey - The new key to rename to
462
+ * @param notifyObject - Whether to notify listeners to the object path
463
+ */
464
+ function rename(path, oldKey, newKey, notifyObject = true) {
465
+ const current = store.get(path);
466
+ if (current === undefined || current === null || typeof current !== 'object') {
467
+ // assign a new object with the new key
468
+ store.set(path, { [newKey]: undefined });
469
+ if (notifyObject) {
470
+ forceNotifyListeners(path, { skipChildren: true });
471
+ }
472
+ return;
473
+ }
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 });
484
+ }
485
+ }
400
486
  /**
401
487
  * React hook that subscribes to and reads a value at a path.
402
488
  *
@@ -487,7 +573,8 @@ const __pc_debug = {
487
573
  getStoreSize: () => store.size,
488
574
  getListenerSize: () => listeners.size,
489
575
  getStore: () => memoryStore,
490
- getStoreValue: (key) => memoryStore.get(key)
576
+ getStoreValue: (key) => memoryStore.get(key),
577
+ getListeners: () => listeners
491
578
  };
492
579
  // Expose debug in browser for quick inspection during development
493
580
  if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') {
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
+ export type * from './form';
1
2
  export { useForm } from './form';
2
- export type { CreateFormOptions, FormState, FormStore } from './form';
3
3
  export { useMemoryStore, type MemoryStore } from './memory';
4
4
  export { createMixedState, type MixedState } from './mixed_state';
5
5
  export type * from './path';
package/dist/memory.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { FieldValues } from './path';
2
- import type { DeepProxy, State } from './types';
2
+ import type { State, ValueState } from './types';
3
3
  export { useMemoryStore, type MemoryStore };
4
4
  /**
5
5
  * A component local store with React bindings.
@@ -10,8 +10,8 @@ export { useMemoryStore, type MemoryStore };
10
10
  * - Type-safe paths using FieldPath.
11
11
  * - Dynamic deep access via Proxy for ergonomic usage like `state.a.b.c.use()` and `state.a.b.c.set(v)`.
12
12
  */
13
- type MemoryStore<T extends FieldValues> = State<T> & {
14
- [K in keyof T]: NonNullable<T[K]> extends object ? DeepProxy<T[K]> : State<T[K]>;
13
+ type MemoryStore<T extends FieldValues> = ValueState<T> & {
14
+ [K in keyof T]-?: State<T[K]>;
15
15
  };
16
16
  /**
17
17
  * React hook that creates a component-scoped memory store.
@@ -1,10 +1,10 @@
1
- import type { Prettify, State } from './types';
1
+ import type { Prettify, ValueState } from './types';
2
2
  export { createMixedState, type MixedState };
3
3
  /**
4
4
  * A combined state that aggregates multiple independent states into a tuple.
5
5
  * Provides read-only access via `value`, `use`, `Render`, and `Show`.
6
6
  */
7
- type MixedState<T extends readonly unknown[]> = Prettify<Pick<State<Readonly<T>>, 'value' | 'use' | 'Render' | 'Show'>>;
7
+ type MixedState<T extends readonly unknown[]> = Prettify<Pick<ValueState<Readonly<Required<T>>>, 'value' | 'use' | 'Render' | 'Show'>>;
8
8
  /**
9
9
  * Creates a mixed state that combines multiple states into a tuple.
10
10
  *
@@ -21,5 +21,5 @@ type MixedState<T extends readonly unknown[]> = Prettify<Pick<State<Readonly<T>>
21
21
  * </mixedState.Render>
22
22
  */
23
23
  declare function createMixedState<T extends readonly unknown[]>(...states: {
24
- [K in keyof T]: State<T[K]>;
24
+ [K in keyof T]-?: ValueState<T[K]>;
25
25
  }): MixedState<T>;
package/dist/node.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { FieldValues } from './path';
1
+ import type { FieldPath, FieldPathValue, FieldValues } from './path';
2
2
  import type { State, StoreRoot } from './types';
3
3
  export { createNode, createRootNode, type Extension };
4
4
  /**
@@ -11,7 +11,7 @@ export { createNode, createRootNode, type Extension };
11
11
  * @param initialPath - Starting path segment (default: empty string for root)
12
12
  * @returns A proxy that intercepts property access and returns nested proxies or state methods
13
13
  */
14
- declare function createRootNode<T extends FieldValues>(storeApi: StoreRoot<T>, initialPath?: string): State<T>;
14
+ declare function createRootNode<T extends FieldValues, P extends FieldPath<T>>(storeApi: StoreRoot<T>, initialPath?: P): State<FieldPathValue<T, P>>;
15
15
  /**
16
16
  * Extension interface for adding custom getters/setters to proxy nodes.
17
17
  * Used internally by form handling to add error-related methods.
package/dist/node.js CHANGED
@@ -30,13 +30,14 @@ function createRootNode(storeApi, initialPath = '') {
30
30
  */
31
31
  function createNode(storeApi, path, cache, extensions, from = unchanged, to = unchanged) {
32
32
  const isDerived = from !== unchanged || to !== unchanged;
33
+ const fieldName = path.split('.').pop();
33
34
  if (!isDerived && cache.has(path)) {
34
35
  return cache.get(path);
35
36
  }
36
37
  const proxy = new Proxy({}, {
37
38
  get(_target, prop) {
38
39
  if (prop === 'field') {
39
- return path.split('.').pop();
40
+ return fieldName;
40
41
  }
41
42
  if (prop === 'use') {
42
43
  return () => from(storeApi.use(path));
@@ -45,7 +46,10 @@ function createNode(storeApi, path, cache, extensions, from = unchanged, to = un
45
46
  return (delay) => from(storeApi.useDebounce(path, delay));
46
47
  }
47
48
  if (prop === 'useState') {
48
- return () => [from(storeApi.use(path)), (value) => storeApi.set(path, to(value))];
49
+ return () => {
50
+ const value = storeApi.use(path);
51
+ return [from(value), (next) => storeApi.set(path, to(next))];
52
+ };
49
53
  }
50
54
  if (prop === 'value') {
51
55
  return from(storeApi.value(path));
@@ -75,116 +79,126 @@ function createNode(storeApi, path, cache, extensions, from = unchanged, to = un
75
79
  }
76
80
  if (prop === 'derived') {
77
81
  if (isDerived) {
78
- throw new Error('Derived method cannot be called on a derived node');
82
+ throw new Error(`Derived method cannot be called on a derived node: ${path}`);
79
83
  }
80
84
  return ({ from, to }) => createNode(storeApi, path, cache, extensions, from, to);
81
85
  }
82
- if (Array.isArray(storeApi.value(path))) {
86
+ if (prop === 'notify') {
87
+ return () => storeApi.notify(path);
88
+ }
89
+ if (prop === 'ensureArray') {
90
+ return () => createNode(storeApi, path, cache, extensions, value => ensureArray(value, from), unchanged);
91
+ }
92
+ if (prop === 'ensureObject') {
93
+ return () => createNode(storeApi, path, cache, extensions, value => ensureObject(value, from), to);
94
+ }
95
+ if (prop === 'withDefault') {
96
+ return (defaultValue) => createNode(storeApi, path, cache, extensions, value => withDefault(value, defaultValue, from), to);
97
+ }
98
+ if (isObjectMethod(prop)) {
99
+ const derivedValue = from(storeApi.value(path));
100
+ if (derivedValue !== undefined && typeof derivedValue !== 'object') {
101
+ throw new Error(`Expected object at path ${path}, got ${typeof derivedValue}`);
102
+ }
103
+ if (prop === 'rename') {
104
+ return (oldKey, newKey, notifyObject) => {
105
+ storeApi.rename(path, oldKey, newKey, notifyObject);
106
+ };
107
+ }
108
+ }
109
+ if (isArrayMethod(prop)) {
110
+ const derivedValue = from(storeApi.value(path));
111
+ if (derivedValue !== undefined && !Array.isArray(derivedValue)) {
112
+ throw new Error(`Expected array at path ${path}, got ${typeof derivedValue}`);
113
+ }
114
+ const currentArray = derivedValue ? [...derivedValue] : [];
83
115
  if (prop === 'at') {
84
116
  return (index) => {
85
117
  const nextPath = path ? `${path}.${index}` : String(index);
86
- return createNode(storeApi, nextPath, cache, extensions, from, to);
118
+ return createNode(storeApi, nextPath, cache, extensions);
87
119
  };
88
120
  }
89
121
  if (prop === 'length') {
90
- const value = from(storeApi.value(path));
91
- return Array.isArray(value) ? value.length : undefined;
122
+ return currentArray.length;
92
123
  }
93
124
  // Array mutation methods
94
125
  if (prop === 'push') {
95
126
  return (...items) => {
96
- const currentArray = from(storeApi.value(path)) ?? [];
97
- const transformedItems = isDerived ? items.map(to) : items;
98
- const newArray = [...currentArray, ...transformedItems];
99
- storeApi.set(path, isDerived ? newArray.map(from) : newArray);
127
+ const newArray = [...currentArray, ...items];
128
+ storeApi.set(path, isDerived ? newArray.map(to) : newArray);
100
129
  return newArray.length;
101
130
  };
102
131
  }
103
132
  if (prop === 'pop') {
104
133
  return () => {
105
- const currentArray = from(storeApi.value(path)) ?? [];
106
134
  if (currentArray.length === 0)
107
135
  return undefined;
108
136
  const newArray = currentArray.slice(0, -1);
109
137
  const poppedItem = currentArray[currentArray.length - 1];
110
- storeApi.set(path, isDerived ? newArray.map(from) : newArray);
138
+ storeApi.set(path, isDerived ? newArray.map(to) : newArray);
111
139
  return poppedItem;
112
140
  };
113
141
  }
114
142
  if (prop === 'shift') {
115
143
  return () => {
116
- const currentArray = from(storeApi.value(path)) ?? [];
117
144
  if (currentArray.length === 0)
118
145
  return undefined;
119
146
  const newArray = currentArray.slice(1);
120
147
  const shiftedItem = currentArray[0];
121
- storeApi.set(path, isDerived ? newArray.map(from) : newArray);
148
+ storeApi.set(path, isDerived ? newArray.map(to) : newArray);
122
149
  return shiftedItem;
123
150
  };
124
151
  }
125
152
  if (prop === 'unshift') {
126
153
  return (...items) => {
127
- const currentArray = from(storeApi.value(path)) ?? [];
128
- const transformedItems = isDerived ? items.map(to) : items;
129
- const newArray = [...transformedItems, ...currentArray];
130
- storeApi.set(path, isDerived ? newArray.map(from) : newArray);
154
+ const newArray = [...items, ...currentArray];
155
+ storeApi.set(path, isDerived ? newArray.map(to) : newArray);
131
156
  return newArray.length;
132
157
  };
133
158
  }
134
159
  if (prop === 'splice') {
135
160
  return (start, deleteCount, ...items) => {
136
- const currentArray = from(storeApi.value(path)) ?? [];
137
- const newArray = [...currentArray];
138
- const transformedItems = isDerived ? items.map(to) : items;
139
- const deletedItems = newArray.splice(start, deleteCount ?? 0, ...transformedItems);
140
- storeApi.set(path, isDerived ? newArray.map(from) : newArray);
161
+ const deletedItems = currentArray.splice(start, deleteCount ?? 0, ...items);
162
+ storeApi.set(path, isDerived ? currentArray.map(to) : currentArray);
141
163
  return deletedItems;
142
164
  };
143
165
  }
144
166
  if (prop === 'reverse') {
145
167
  return () => {
146
- const currentArray = from(storeApi.value(path)) ?? [];
147
168
  if (!Array.isArray(currentArray))
148
169
  return [];
149
- const newArray = [...currentArray].reverse();
150
- storeApi.set(path, isDerived ? newArray.map(from) : newArray);
151
- return newArray;
170
+ currentArray.reverse();
171
+ storeApi.set(path, isDerived ? currentArray.map(to) : currentArray);
172
+ return currentArray;
152
173
  };
153
174
  }
154
175
  if (prop === 'sort') {
155
176
  return (compareFn) => {
156
- const currentArray = from(storeApi.value(path)) ?? [];
157
- const newArray = [...currentArray].sort(compareFn);
158
- storeApi.set(path, isDerived ? newArray.map(from) : newArray);
159
- return newArray;
177
+ currentArray.sort(compareFn);
178
+ storeApi.set(path, isDerived ? currentArray.map(to) : currentArray);
179
+ return currentArray;
160
180
  };
161
181
  }
162
182
  if (prop === 'fill') {
163
183
  return (value, start, end) => {
164
- const currentArray = from(storeApi.value(path)) ?? [];
165
- const newArray = [...currentArray].fill(value, start, end);
166
- storeApi.set(path, isDerived ? newArray.map(from) : newArray);
167
- return newArray;
184
+ currentArray.fill(value, start, end);
185
+ storeApi.set(path, isDerived ? currentArray.map(to) : currentArray);
186
+ return currentArray;
168
187
  };
169
188
  }
170
189
  if (prop === 'copyWithin') {
171
190
  return (target, start, end) => {
172
- const currentArray = from(storeApi.value(path)) ?? [];
173
- const newArray = [...currentArray].copyWithin(target, start, end);
174
- storeApi.set(path, isDerived ? newArray.map(from) : newArray);
175
- return newArray;
191
+ currentArray.copyWithin(target, start, end);
192
+ storeApi.set(path, isDerived ? currentArray.map(to) : currentArray);
193
+ return currentArray;
176
194
  };
177
195
  }
178
196
  if (prop === 'sortedInsert') {
179
197
  return (cmp, ...items) => {
180
- const currentArray = from(storeApi.value(path)) ?? [];
181
198
  if (typeof cmp !== 'function')
182
- return isDerived ? currentArray.map(from).length : currentArray.length;
183
- // Create a copy of the current array
184
- const newArray = isDerived ? currentArray.map(from) : [...currentArray];
185
- const transformedItems = isDerived ? items.map(to) : items;
186
- // Insert each item in sorted order using binary search
187
- for (const item of transformedItems) {
199
+ return currentArray.length;
200
+ const newArray = [...currentArray];
201
+ for (const item of items) {
188
202
  let left = 0;
189
203
  let right = newArray.length;
190
204
  // Binary search to find insertion point
@@ -200,7 +214,7 @@ function createNode(storeApi, path, cache, extensions, from = unchanged, to = un
200
214
  // Insert at the found position
201
215
  newArray.splice(left, 0, item);
202
216
  }
203
- storeApi.set(path, isDerived ? newArray.map(from) : newArray);
217
+ storeApi.set(path, isDerived ? newArray.map(to) : newArray);
204
218
  return newArray.length;
205
219
  };
206
220
  }
@@ -210,8 +224,7 @@ function createNode(storeApi, path, cache, extensions, from = unchanged, to = un
210
224
  }
211
225
  if (typeof prop === 'string' || typeof prop === 'number') {
212
226
  const nextPath = path ? `${path}.${prop}` : String(prop);
213
- // Always return a proxy
214
- return createNode(storeApi, nextPath, cache, extensions, from, to);
227
+ return createNode(storeApi, nextPath, cache, extensions);
215
228
  }
216
229
  return undefined;
217
230
  },
@@ -220,7 +233,7 @@ function createNode(storeApi, path, cache, extensions, from = unchanged, to = un
220
233
  return extensions[prop]?.set(value);
221
234
  }
222
235
  if (typeof prop === 'string' || typeof prop === 'number') {
223
- const nextPath = path ? `${path}.${prop}` : prop;
236
+ const nextPath = path ? `${path}.${prop}` : String(prop);
224
237
  storeApi.set(nextPath, to(value));
225
238
  return true;
226
239
  }
@@ -232,6 +245,46 @@ function createNode(storeApi, path, cache, extensions, from = unchanged, to = un
232
245
  }
233
246
  return proxy;
234
247
  }
248
+ function isArrayMethod(prop) {
249
+ return (prop === 'at' ||
250
+ prop === 'length' ||
251
+ prop === 'push' ||
252
+ prop === 'pop' ||
253
+ prop === 'shift' ||
254
+ prop === 'unshift' ||
255
+ prop === 'splice' ||
256
+ prop === 'reverse' ||
257
+ prop === 'sort' ||
258
+ prop === 'fill' ||
259
+ prop === 'copyWithin' ||
260
+ prop === 'sortedInsert');
261
+ }
262
+ function isObjectMethod(prop) {
263
+ return prop === 'rename';
264
+ }
235
265
  function unchanged(value) {
236
266
  return value;
237
267
  }
268
+ const EMPTY_ARRAY = [];
269
+ const EMPTY_OBJECT = {};
270
+ function ensureArray(value, from) {
271
+ if (value === undefined || value === null)
272
+ return EMPTY_ARRAY;
273
+ const array = from(value);
274
+ if (Array.isArray(array))
275
+ return array;
276
+ return EMPTY_ARRAY;
277
+ }
278
+ function ensureObject(value, from) {
279
+ if (value === undefined || value === null)
280
+ return EMPTY_OBJECT;
281
+ const obj = from(value);
282
+ if (typeof obj === 'object')
283
+ return obj;
284
+ return EMPTY_OBJECT;
285
+ }
286
+ function withDefault(value, defaultValue, from) {
287
+ if (value === undefined || value === null)
288
+ return defaultValue; // defaultValue should've already matched the type
289
+ return from(value);
290
+ }
package/dist/root.js CHANGED
@@ -1,5 +1,6 @@
1
- import { useCallback, useRef, useState } from 'react';
2
- import { getNestedValue, getSnapshot, isEqual, joinPath, notifyListeners, produce, setLeaf, useDebounce, useObject, useSubscribe } from './impl';
1
+ import { useCallback, useRef, useSyncExternalStore } from 'react';
2
+ import { getNestedValue, getSnapshot, joinPath, notifyListeners, produce, rename, setLeaf, subscribe, useDebounce, useObject, useSubscribe } from './impl';
3
+ import { createRootNode } from './node';
3
4
  export { createStoreRoot };
4
5
  /**
5
6
  * Creates the core store API with path-based methods.
@@ -20,6 +21,7 @@ function createStoreRoot(namespace, defaultValue, options = {}) {
20
21
  }
21
22
  produce(namespace, { ...defaultValue, ...(getSnapshot(namespace) ?? {}) }, true, true);
22
23
  const storeApi = {
24
+ state: (path) => createRootNode(storeApi, path),
23
25
  use: (path) => useObject(namespace, path),
24
26
  useDebounce: (path, delay) => useDebounce(namespace, path, delay),
25
27
  set: (path, value, skipUpdate = false) => {
@@ -31,24 +33,37 @@ function createStoreRoot(namespace, defaultValue, options = {}) {
31
33
  },
32
34
  value: (path) => getSnapshot(joinPath(namespace, path)),
33
35
  reset: (path) => produce(joinPath(namespace, path), undefined, false, memoryOnly),
36
+ rename: (path, oldKey, newKey, notifyObject = true) => rename(joinPath(namespace, path), oldKey, newKey, notifyObject),
34
37
  subscribe: (path, listener) =>
35
38
  // eslint-disable-next-line react-hooks/rules-of-hooks
36
39
  useSubscribe(joinPath(namespace, path), listener),
37
40
  useCompute: (path, fn) => {
38
41
  const fullPath = joinPath(namespace, path);
39
- const initialValue = getSnapshot(fullPath);
40
- const [computedValue, setComputedValue] = useState(() => fn(initialValue));
41
- useSubscribe(fullPath, value => {
42
- const newValue = fn(value);
43
- if (!isEqual(computedValue, newValue)) {
44
- setComputedValue(newValue);
42
+ const fnRef = useRef(fn);
43
+ fnRef.current = fn;
44
+ // Cache to avoid infinite loops - only recompute when store value changes
45
+ const cacheRef = useRef(null);
46
+ const subscribeToPath = useCallback((onStoreChange) => subscribe(fullPath, onStoreChange), [fullPath]);
47
+ const getComputedSnapshot = useCallback(() => {
48
+ const storeValue = getSnapshot(fullPath);
49
+ // Return cached result if store value hasn't changed
50
+ if (cacheRef.current && cacheRef.current.storeValue === storeValue) {
51
+ return cacheRef.current.computed;
45
52
  }
46
- });
47
- return computedValue;
53
+ // Recompute and cache
54
+ const computed = fnRef.current(storeValue);
55
+ cacheRef.current = { storeValue, computed };
56
+ return computed;
57
+ }, [fullPath]);
58
+ return useSyncExternalStore(subscribeToPath, getComputedSnapshot, getComputedSnapshot);
48
59
  },
49
60
  notify: (path) => {
50
61
  const value = getNestedValue(getSnapshot(namespace), path);
51
- return notifyListeners(joinPath(namespace, path), value, value, true, true);
62
+ return notifyListeners(joinPath(namespace, path), value, value, {
63
+ skipRoot: true,
64
+ skipChildren: true,
65
+ forceNotify: true
66
+ });
52
67
  },
53
68
  useState: (path) => {
54
69
  const fullPathRef = useRef(joinPath(namespace, path));
package/dist/store.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { FieldValues } from './path';
2
2
  import { type StoreOptions } from './root';
3
- import type { DeepProxy, State, StoreRoot } from './types';
3
+ import type { State, StoreRoot } from './types';
4
4
  export { createStore, type Store };
5
5
  /**
6
6
  * A persistent, hierarchical, cross-tab synchronized key-value store with React bindings.
@@ -14,7 +14,7 @@ export { createStore, type Store };
14
14
  * - Dynamic deep access via Proxy for ergonomic usage like `store.a.b.c.use()` and `store.a.b.c.set(v)`.
15
15
  */
16
16
  type Store<T extends FieldValues> = StoreRoot<T> & {
17
- [K in keyof T]: NonNullable<T[K]> extends object ? DeepProxy<T[K]> : State<T[K]>;
17
+ [K in keyof T]-?: State<T[K]>;
18
18
  };
19
19
  /**
20
20
  * Creates a persistent, hierarchical store with localStorage backing and cross-tab synchronization.
package/dist/types.d.ts CHANGED
@@ -1,17 +1,14 @@
1
- import type { FieldPath, FieldPathValue, FieldValues } from './path';
2
- export type { ArrayProxy, DeepProxy, DerivedStateProps, Prettify, State, StoreRenderProps, StoreRoot, StoreSetStateAction, StoreShowProps, StoreUse };
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 };
3
3
  type Prettify<T> = {
4
4
  [K in keyof T]: T[K];
5
5
  } & {};
6
- /** Type for nested objects with proxy methods */
7
- type DeepProxy<T> = NonNullable<T> extends readonly (infer U)[] ? ArrayProxy<U> & State<T> : NonNullable<T> extends FieldValues ? {
8
- [K in keyof NonNullable<T>]-?: NonNullable<NonNullable<T>[K]> extends object ? DeepProxy<NonNullable<T>[K]> : State<NonNullable<T>[K]>;
9
- } & State<T> : State<T>;
10
- type ArrayMutationMethods<T> = Pick<Array<T>, 'push' | 'pop' | 'shift' | 'unshift' | 'splice' | 'reverse' | 'sort' | 'fill' | 'copyWithin'>;
6
+ type AllowedKeys<T> = Exclude<keyof T, keyof ValueState<unknown> | keyof ObjectMutationMethods>;
7
+ type ArrayMutationMethods<T> = Prettify<Pick<Array<T>, 'push' | 'pop' | 'shift' | 'unshift' | 'splice' | 'reverse' | 'sort' | 'fill' | 'copyWithin'>>;
11
8
  /** Type for array proxy with index access */
12
- type ArrayProxy<T> = Prettify<ArrayMutationMethods<T>> & {
9
+ type ArrayProxy<T, ElementState = State<T>> = ArrayMutationMethods<T> & {
13
10
  /** Read without subscribing. Returns array or undefined for missing paths. */
14
- readonly value: T[] | undefined;
11
+ readonly value: T[];
15
12
  /**
16
13
  * Length of the underlying array. Runtime may return undefined when the
17
14
  * current value is not an array at the path. Prefer `Array.isArray(x) && x.length` when unsure.
@@ -20,42 +17,50 @@ type ArrayProxy<T> = Prettify<ArrayMutationMethods<T>> & {
20
17
  /** Numeric index access never returns undefined at the type level because
21
18
  * the proxy always returns another proxy object, even if the underlying value doesn't exist.
22
19
  */
23
- [K: number]: T extends object ? DeepProxy<T> : State<T>;
20
+ [K: number]: ElementState;
24
21
  /** Safe accessor that never returns undefined at the type level */
25
- at(index: number): T extends object ? DeepProxy<T> : State<T>;
22
+ at(index: number): ElementState;
26
23
  /** Insert items into the array in sorted order using the provided comparison function. */
27
24
  sortedInsert(cmp: (a: T, b: T) => number, ...items: T[]): number;
28
25
  };
26
+ type ObjectMutationMethods = {
27
+ /** Rename a key in an object. */
28
+ rename: (oldKey: string, newKey: string, notifyObject?: boolean) => void;
29
+ };
29
30
  /** Tuple returned by Store.use(path). */
30
31
  type StoreUse<T> = Readonly<[T | undefined, (value: T | undefined) => void]>;
31
32
  type StoreSetStateAction<T> = (value: T | undefined | ((prev: T) => T), skipUpdate?: boolean) => void;
32
33
  /** Public API returned by createStore(namespace, defaultValue). */
33
34
  type StoreRoot<T extends FieldValues> = {
35
+ /** Get the state object for a path. */
36
+ state: <P extends FieldPath<T>>(path: P) => State<FieldPathValue<T, P>>;
34
37
  /** Subscribe and read the value at path. Re-renders when the value changes. */
35
38
  use: <P extends FieldPath<T>>(path: P) => FieldPathValue<T, P> | undefined;
36
39
  /** Subscribe and read the debounced value at path. Re-renders when the value changes. */
37
40
  useDebounce: <P extends FieldPath<T>>(path: P, delay: number) => FieldPathValue<T, P> | undefined;
38
- /** Set value at path (creates intermediate nodes as needed). */
39
- set: <P extends FieldPath<T>>(path: P, value: FieldPathValue<T, P> | ((prev: FieldPathValue<T, P> | undefined) => FieldPathValue<T, P>), skipUpdate?: boolean) => void;
41
+ /** Convenience hook returning [value, setValue] for the path. */
42
+ useState: <P extends FieldPath<T>>(path: P) => StoreUse<FieldPathValue<T, P>>;
40
43
  /** Read without subscribing. */
41
44
  value: <P extends FieldPath<T>>(path: P) => FieldPathValue<T, P> | undefined;
45
+ /** Set value at path (creates intermediate nodes as needed). */
46
+ set: <P extends FieldPath<T>>(path: P, value: FieldPathValue<T, P> | ((prev: FieldPathValue<T, P> | undefined) => FieldPathValue<T, P>), skipUpdate?: boolean) => void;
42
47
  /** Delete value at path (for arrays, removes index; for objects, deletes key). */
43
48
  reset: <P extends FieldPath<T>>(path: P) => void;
49
+ /** Rename a key in an object. */
50
+ rename: <P extends FieldPath<T>>(path: P, oldKey: string, newKey: string, notifyObject?: boolean) => void;
44
51
  /** Subscribe to changes at path and invoke listener with the new value. */
45
52
  subscribe: <P extends FieldPath<T>>(path: P, listener: (value: FieldPathValue<T, P>) => void) => void;
46
53
  /** Compute a derived value from the current value, similar to useState + useMemo */
47
54
  useCompute: <P extends FieldPath<T>, R>(path: P, fn: (value: FieldPathValue<T, P>) => R) => R;
48
55
  /** Notify listeners at path. */
49
56
  notify: <P extends FieldPath<T>>(path: P) => void;
50
- /** Convenience hook returning [value, setValue] for the path. */
51
- useState: <P extends FieldPath<T>>(path: P) => StoreUse<FieldPathValue<T, P>>;
52
57
  /** Render-prop helper for inline usage. */
53
58
  Render: <P extends FieldPath<T>>(props: FieldPathValue<T, P> extends undefined ? never : StoreRenderProps<T, P>) => React.ReactNode;
54
59
  /** Show or hide children based on the value at the path. */
55
60
  Show: <P extends FieldPath<T>>(props: FieldPathValue<T, P> extends undefined ? never : StoreShowProps<T, P>) => React.ReactNode;
56
61
  };
57
62
  /** Common methods available on any deep proxy node */
58
- type State<T> = {
63
+ type ValueState<T> = {
59
64
  /** Read without subscribing. */
60
65
  readonly value: T;
61
66
  /** The field name for the proxy. */
@@ -74,10 +79,27 @@ type State<T> = {
74
79
  subscribe(listener: (value: T) => void): void;
75
80
  /** Compute a derived value from the current value, similar to useState + useMemo */
76
81
  useCompute: <R>(fn: (value: T) => R) => R;
77
- /** Virtual state derived from the current value. */
82
+ /** Ensure the value is an array. */
83
+ ensureArray(): NonNullable<T> extends (infer U)[] ? ArrayState<U> : never;
84
+ /** Ensure the value is an object. */
85
+ ensureObject(): NonNullable<T> extends FieldValues ? ObjectState<NonNullable<T>> : never;
86
+ /** Return a new state with a default value, and make the type non-nullable */
87
+ withDefault(defaultValue: T): State<NonNullable<T>>;
88
+ /** Virtual state derived from the current value.
89
+ *
90
+ * @returns ArrayState if the derived value is an array, ObjectState if the derived value is an object, otherwise State.
91
+ * @example
92
+ * const state = store.a.b.c.derived({
93
+ * from: value => value + 1,
94
+ * to: value => value - 1
95
+ * })
96
+ * state.use() // returns the derived value
97
+ * state.set(10) // sets the derived value
98
+ * state.reset() // resets the derived value
99
+ */
78
100
  derived: <R>({ from, to }: {
79
- from: (value: T | undefined) => R;
80
- to: (value: R) => T | undefined;
101
+ from?: (value: T | undefined) => R;
102
+ to?: (value: R) => T | undefined;
81
103
  }) => State<R>;
82
104
  /** Notify listener of current value. */
83
105
  notify(): void;
@@ -102,7 +124,14 @@ type State<T> = {
102
124
  children: React.ReactNode;
103
125
  on: (value: T) => boolean;
104
126
  }) => React.ReactNode;
105
- } & (NonNullable<T> extends readonly (infer U)[] ? ArrayProxy<U> : unknown);
127
+ };
128
+ type MaybeNullable<T, Nullable extends boolean = false> = Nullable extends true ? T | undefined : T;
129
+ type IsNullable<T> = T extends undefined | null ? true : false;
130
+ 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
+ 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;
106
135
  /** Props for Store.Render helper. */
107
136
  type StoreRenderProps<T extends FieldValues, P extends FieldPath<T>> = {
108
137
  path: P;
@@ -115,6 +144,6 @@ type StoreShowProps<T extends FieldValues, P extends FieldPath<T>> = {
115
144
  on: (value: FieldPathValue<T, P> | undefined) => boolean;
116
145
  };
117
146
  type DerivedStateProps<T, R> = {
118
- from: (value: T | undefined) => R;
119
- to: (value: R) => T | undefined;
147
+ from?: (value: T | undefined) => R;
148
+ to?: (value: R) => T | undefined;
120
149
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "juststore",
3
- "version": "0.1.4",
3
+ "version": "0.3.2",
4
4
  "description": "A small, expressive, and type-safe state management library for React.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",