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 +35 -29
- package/dist/form.d.ts +30 -11
- package/dist/impl.d.ts +29 -2
- package/dist/impl.js +197 -110
- package/dist/index.d.ts +1 -1
- package/dist/memory.d.ts +3 -3
- package/dist/mixed_state.d.ts +3 -3
- package/dist/node.d.ts +2 -2
- package/dist/node.js +104 -51
- package/dist/root.js +26 -11
- package/dist/store.d.ts +2 -2
- package/dist/types.d.ts +51 -22
- package/package.json +1 -1
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
|
|
458
|
-
|
|
|
459
|
-
| `.
|
|
460
|
-
| `.
|
|
461
|
-
| `.
|
|
462
|
-
| `.
|
|
463
|
-
| `.
|
|
464
|
-
| `.set(path,
|
|
465
|
-
| `.
|
|
466
|
-
| `.
|
|
467
|
-
| `.
|
|
468
|
-
| `.
|
|
469
|
-
| `.
|
|
470
|
-
| `.
|
|
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
|
|
475
|
-
|
|
|
476
|
-
| `.use()`
|
|
477
|
-
| `.useDebounce(ms)`
|
|
478
|
-
| `.useState()`
|
|
479
|
-
| `.value`
|
|
480
|
-
| `.set(value)`
|
|
481
|
-
| `.set(fn)`
|
|
482
|
-
| `.reset()`
|
|
483
|
-
| `.subscribe(fn)`
|
|
484
|
-
| `.
|
|
485
|
-
| `.
|
|
486
|
-
| `.
|
|
487
|
-
| `.
|
|
488
|
-
| `.
|
|
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,
|
|
3
|
-
export { useForm, type CreateFormOptions, type DeepNonNullable, type
|
|
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
|
|
16
|
-
|
|
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
|
|
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> =
|
|
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
|
|
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]
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
126
|
-
|
|
108
|
+
const existing = current[index];
|
|
109
|
+
let next;
|
|
110
|
+
if (existing === null || existing === undefined) {
|
|
111
|
+
next = isNextIndex ? [] : {};
|
|
127
112
|
}
|
|
128
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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
|
|
165
|
-
* @returns
|
|
171
|
+
* @param key - Full key string
|
|
172
|
+
* @returns Namespace
|
|
173
|
+
* @example
|
|
174
|
+
* getNamespace('app.user.name') // 'app'
|
|
166
175
|
*/
|
|
167
|
-
function
|
|
168
|
-
|
|
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
|
|
183
|
+
* Extracts the namespace and path from a full key.
|
|
172
184
|
*
|
|
173
|
-
* @param key - Full key string
|
|
174
|
-
* @returns
|
|
185
|
+
* @param key - Full key string
|
|
186
|
+
* @returns [namespace, path]
|
|
187
|
+
* @example
|
|
188
|
+
* splitNSPath('app.user.name') // ['app', 'user.name']
|
|
175
189
|
*/
|
|
176
|
-
function
|
|
177
|
-
const
|
|
178
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
228
|
+
// exact match only
|
|
229
|
+
const listenerSet = listeners.get(key);
|
|
230
|
+
if (listenerSet) {
|
|
231
|
+
listenerSet.forEach(listener => listener());
|
|
204
232
|
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
216
|
-
|
|
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('
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
253
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
387
|
-
|
|
388
|
-
|
|
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 {
|
|
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> =
|
|
14
|
-
[K in keyof T]
|
|
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.
|
package/dist/mixed_state.d.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import type { Prettify,
|
|
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<
|
|
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]
|
|
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
|
|
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
|
|
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 () =>
|
|
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(
|
|
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 (
|
|
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
|
|
118
|
+
return createNode(storeApi, nextPath, cache, extensions);
|
|
87
119
|
};
|
|
88
120
|
}
|
|
89
121
|
if (prop === 'length') {
|
|
90
|
-
|
|
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
|
|
97
|
-
|
|
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(
|
|
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(
|
|
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
|
|
128
|
-
|
|
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
|
|
137
|
-
|
|
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
|
-
|
|
150
|
-
storeApi.set(path, isDerived ?
|
|
151
|
-
return
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
|
183
|
-
|
|
184
|
-
const
|
|
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(
|
|
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
|
-
|
|
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,
|
|
2
|
-
import { getNestedValue, getSnapshot,
|
|
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
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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,
|
|
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 {
|
|
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]
|
|
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,
|
|
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
|
-
|
|
7
|
-
type
|
|
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
|
|
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[]
|
|
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]:
|
|
20
|
+
[K: number]: ElementState;
|
|
24
21
|
/** Safe accessor that never returns undefined at the type level */
|
|
25
|
-
at(index: number):
|
|
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
|
-
/**
|
|
39
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
80
|
-
to
|
|
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
|
-
}
|
|
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
|
|
119
|
-
to
|
|
147
|
+
from?: (value: T | undefined) => R;
|
|
148
|
+
to?: (value: R) => T | undefined;
|
|
120
149
|
};
|