juststore 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/form.d.ts +34 -0
- package/dist/form.js +111 -0
- package/dist/impl.d.ts +19 -0
- package/dist/impl.js +378 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +3 -0
- package/dist/local_storage.d.ts +7 -0
- package/dist/local_storage.js +43 -0
- package/dist/memory.d.ts +21 -0
- package/dist/memory.js +17 -0
- package/dist/mixed_state.d.ts +21 -0
- package/dist/mixed_state.js +34 -0
- package/dist/node.d.ts +11 -0
- package/dist/node.js +218 -0
- package/dist/path.d.ts +135 -0
- package/dist/path.js +2 -0
- package/dist/root.d.ts +7 -0
- package/dist/root.js +63 -0
- package/dist/store.d.ts +19 -0
- package/dist/store.js +17 -0
- package/dist/types.d.ts +118 -0
- package/dist/types.js +1 -0
- package/package.json +70 -0
package/dist/form.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
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 };
|
|
4
|
+
type FormCommon = {
|
|
5
|
+
/** Subscribe and read the error at path. Re-renders when the error changes. */
|
|
6
|
+
useError: () => string | undefined;
|
|
7
|
+
/** Read the error at path without subscribing. */
|
|
8
|
+
readonly error: string | undefined;
|
|
9
|
+
/** Set the error at path. */
|
|
10
|
+
setError: (error: string | undefined) => void;
|
|
11
|
+
};
|
|
12
|
+
type FormArrayProxy<T> = ArrayProxy<T> & FormCommon;
|
|
13
|
+
type FormState<T> = State<T> & FormCommon;
|
|
14
|
+
/** Type for nested objects with proxy methods */
|
|
15
|
+
type FormDeepProxy<T> = NonNullable<T> extends readonly (infer U)[] ? FormArrayProxy<U> & FormState<T> : NonNullable<T> extends FieldValues ? {
|
|
16
|
+
[K in keyof NonNullable<T>]-?: NonNullable<NonNullable<T>[K]> extends object ? FormDeepProxy<NonNullable<T>[K]> : FormState<NonNullable<T>[K]>;
|
|
17
|
+
} & FormState<T> : FormState<T>;
|
|
18
|
+
/** Type for nested objects with proxy methods */
|
|
19
|
+
type DeepNonNullable<T> = NonNullable<T> extends readonly (infer U)[] ? U[] : NonNullable<T> extends FieldValues ? {
|
|
20
|
+
[K in keyof NonNullable<T>]-?: DeepNonNullable<NonNullable<T>[K]>;
|
|
21
|
+
} : NonNullable<T>;
|
|
22
|
+
type FormStore<T extends FieldValues> = FormDeepProxy<T> & {
|
|
23
|
+
clearErrors(): void;
|
|
24
|
+
handleSubmit(onSubmit: (values: T) => void): (e: React.FormEvent) => void;
|
|
25
|
+
};
|
|
26
|
+
type NoEmptyValidator = 'not-empty';
|
|
27
|
+
type RegexValidator = RegExp;
|
|
28
|
+
type FunctionValidator<T extends FieldValues> = (value: FieldPathValue<T, FieldPath<T>> | undefined, state: FormStore<T>) => string | undefined;
|
|
29
|
+
type Validator<T extends FieldValues> = NoEmptyValidator | RegexValidator | FunctionValidator<T>;
|
|
30
|
+
type FieldConfig<T extends FieldValues> = {
|
|
31
|
+
validate?: Validator<T>;
|
|
32
|
+
};
|
|
33
|
+
type CreateFormOptions<T extends FieldValues> = Partial<Record<FieldPath<T>, FieldConfig<T>>>;
|
|
34
|
+
declare function useForm<T extends FieldValues>(defaultValue: T, fieldConfigs?: CreateFormOptions<T>): FormStore<T>;
|
package/dist/form.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
'use client';
|
|
3
|
+
import { pascalCase } from 'change-case';
|
|
4
|
+
import { useId } from 'react';
|
|
5
|
+
import { getSnapshot, produce } from './impl';
|
|
6
|
+
import { createNode } from './node';
|
|
7
|
+
import { createStoreRoot } from './root';
|
|
8
|
+
export { useForm };
|
|
9
|
+
function useForm(defaultValue, fieldConfigs = {}) {
|
|
10
|
+
const formId = useId();
|
|
11
|
+
const namespace = `form:${formId}`;
|
|
12
|
+
const errorNamespace = `errors.${namespace}`;
|
|
13
|
+
const storeApi = createStoreRoot(namespace, defaultValue, { memoryOnly: true });
|
|
14
|
+
const errorStore = createStoreRoot(errorNamespace, {}, { memoryOnly: true });
|
|
15
|
+
const formStore = {
|
|
16
|
+
clearErrors: () => produce(errorNamespace, undefined, false, true),
|
|
17
|
+
handleSubmit: (onSubmit) => (e) => {
|
|
18
|
+
e.preventDefault();
|
|
19
|
+
// disable submit if there are errors
|
|
20
|
+
if (Object.keys(getSnapshot(errorNamespace) ?? {}).length === 0) {
|
|
21
|
+
onSubmit(getSnapshot(namespace));
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
const store = new Proxy(storeApi, {
|
|
26
|
+
get(_target, prop) {
|
|
27
|
+
if (prop in formStore) {
|
|
28
|
+
return formStore[prop];
|
|
29
|
+
}
|
|
30
|
+
if (prop in storeApi) {
|
|
31
|
+
return storeApi[prop];
|
|
32
|
+
}
|
|
33
|
+
if (typeof prop === 'string') {
|
|
34
|
+
return createFormProxy(storeApi, errorStore, prop);
|
|
35
|
+
}
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
for (const entry of Object.entries(fieldConfigs)) {
|
|
40
|
+
const [path, config] = entry;
|
|
41
|
+
const validator = getValidator(path, config?.validate);
|
|
42
|
+
if (validator) {
|
|
43
|
+
storeApi.subscribe(path, (value) => {
|
|
44
|
+
const error = validator(value, store);
|
|
45
|
+
if (!error) {
|
|
46
|
+
errorStore.reset(path);
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
errorStore.set(path, error);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return store;
|
|
55
|
+
}
|
|
56
|
+
const createFormProxy = (storeApi, errorStore, path) => {
|
|
57
|
+
const proxyCache = new Map();
|
|
58
|
+
const useError = () => errorStore.use(path);
|
|
59
|
+
const getError = () => errorStore.value(path);
|
|
60
|
+
const setError = (error) => {
|
|
61
|
+
errorStore.set(path, error);
|
|
62
|
+
return true;
|
|
63
|
+
};
|
|
64
|
+
return createNode(storeApi, path, proxyCache, {
|
|
65
|
+
useError: {
|
|
66
|
+
get: () => useError
|
|
67
|
+
},
|
|
68
|
+
error: {
|
|
69
|
+
get: getError
|
|
70
|
+
},
|
|
71
|
+
setError: {
|
|
72
|
+
get: () => setError
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
};
|
|
76
|
+
function getValidator(field, validator) {
|
|
77
|
+
if (!validator) {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
if (validator === 'not-empty') {
|
|
81
|
+
return (value) => validateNoEmpty(field, value);
|
|
82
|
+
}
|
|
83
|
+
if (validator instanceof RegExp) {
|
|
84
|
+
return (value) => validateRegex(field, value, validator);
|
|
85
|
+
}
|
|
86
|
+
return validator;
|
|
87
|
+
}
|
|
88
|
+
function validateNoEmpty(field, value) {
|
|
89
|
+
if (!stringValue(value)) {
|
|
90
|
+
return `${pascalCase(field)} is required`;
|
|
91
|
+
}
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
function validateRegex(field, value, regex) {
|
|
95
|
+
if (!regex.test(stringValue(value))) {
|
|
96
|
+
return `${pascalCase(field)} is invalid`;
|
|
97
|
+
}
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
function stringValue(v) {
|
|
101
|
+
if (typeof v === 'string') {
|
|
102
|
+
return v;
|
|
103
|
+
}
|
|
104
|
+
if (typeof v === 'number') {
|
|
105
|
+
return String(v);
|
|
106
|
+
}
|
|
107
|
+
if (typeof v === 'boolean') {
|
|
108
|
+
return String(v);
|
|
109
|
+
}
|
|
110
|
+
return '';
|
|
111
|
+
}
|
package/dist/impl.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { FieldPath, FieldPathValue, FieldValues } from './path';
|
|
2
|
+
export { getNestedValue, getSnapshot, joinPath, notifyListeners, produce, setLeaf, useDebounce, useObject, useSubscribe };
|
|
3
|
+
declare function joinPath(namespace: string, path?: string): string;
|
|
4
|
+
/** Get a nested value from an object/array using a dot-separated path. */
|
|
5
|
+
declare function getNestedValue(obj: unknown, path: string): unknown;
|
|
6
|
+
/** Notify exact, root, and affected child listeners for a given key change. */
|
|
7
|
+
declare function notifyListeners(key: string, oldValue: unknown, newValue: unknown, skipRoot?: boolean, skipChildren?: boolean): void;
|
|
8
|
+
/** Snapshot getter used by React's useSyncExternalStore. */
|
|
9
|
+
declare function getSnapshot(key: string): unknown;
|
|
10
|
+
/** Core mutation function that updates store and notifies listeners. */
|
|
11
|
+
declare function produce(key: string, value: unknown, skipUpdate?: boolean, memoryOnly?: boolean): void;
|
|
12
|
+
/** React hook: subscribe to and read a namespaced path value. */
|
|
13
|
+
declare function useObject<T extends FieldValues, P extends FieldPath<T>>(key: string, path?: P): FieldPathValue<T, P> | undefined;
|
|
14
|
+
/** React hook: subscribe to and read a namespaced path debounced value. */
|
|
15
|
+
declare function useDebounce<T extends FieldValues, P extends FieldPath<T>>(key: string, path: P, delay: number): FieldPathValue<T, P> | undefined;
|
|
16
|
+
/** Effectful subscription helper that calls onChange with the latest value. */
|
|
17
|
+
declare function useSubscribe<T>(key: string, onChange: (value: T) => void): void;
|
|
18
|
+
/** Set a leaf value under namespace.path, optionally skipping notifications. */
|
|
19
|
+
declare function setLeaf<T extends FieldValues, P extends FieldPath<T>>(key: string, path: P, value: FieldPathValue<T, P> | undefined, skipUpdate?: boolean, memoryOnly?: boolean): void;
|
package/dist/impl.js
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
|
|
2
|
+
import isEqual from 'react-fast-compare';
|
|
3
|
+
import { localStorageDelete, localStorageGet, localStorageSet } from './local_storage';
|
|
4
|
+
export { getNestedValue, getSnapshot, joinPath, notifyListeners, produce, setLeaf, useDebounce, useObject, useSubscribe };
|
|
5
|
+
const memoryStore = new Map();
|
|
6
|
+
function joinPath(namespace, path) {
|
|
7
|
+
if (!path)
|
|
8
|
+
return namespace;
|
|
9
|
+
return namespace + '.' + path;
|
|
10
|
+
}
|
|
11
|
+
// Path traversal utilities
|
|
12
|
+
/** Get a nested value from an object/array using a dot-separated path. */
|
|
13
|
+
function getNestedValue(obj, path) {
|
|
14
|
+
if (!path)
|
|
15
|
+
return obj;
|
|
16
|
+
const segments = path.split('.');
|
|
17
|
+
let current = obj;
|
|
18
|
+
for (const segment of segments) {
|
|
19
|
+
if (current === null || current === undefined)
|
|
20
|
+
return undefined;
|
|
21
|
+
if (typeof current !== 'object')
|
|
22
|
+
return undefined;
|
|
23
|
+
if (Array.isArray(current)) {
|
|
24
|
+
const index = Number(segment);
|
|
25
|
+
if (Number.isNaN(index))
|
|
26
|
+
return undefined;
|
|
27
|
+
current = current[index];
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
current = current[segment];
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return current;
|
|
34
|
+
}
|
|
35
|
+
function tryStructuredClone(obj) {
|
|
36
|
+
if (obj === null || obj === undefined)
|
|
37
|
+
return null;
|
|
38
|
+
if (typeof obj !== 'object')
|
|
39
|
+
return obj;
|
|
40
|
+
// Fast path for array type
|
|
41
|
+
if (Array.isArray(obj)) {
|
|
42
|
+
// Check if array needs deep cloning (contains objects/arrays)
|
|
43
|
+
const needsDeepClone = obj.some(item => item !== null && typeof item === 'object');
|
|
44
|
+
if (!needsDeepClone) {
|
|
45
|
+
// Array of primitives - just return new array reference
|
|
46
|
+
return [...obj];
|
|
47
|
+
}
|
|
48
|
+
return obj.map(item => tryStructuredClone(item));
|
|
49
|
+
}
|
|
50
|
+
// Fallback to structuredClone for complex objects
|
|
51
|
+
try {
|
|
52
|
+
return structuredClone(obj);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Immutable set/delete of a nested value using a dot-separated path.
|
|
60
|
+
* - Creates intermediate nodes (array if next segment is a numeric index).
|
|
61
|
+
* - If value is undefined, deletes object key or removes array index.
|
|
62
|
+
* - Returns a new root object/array; when path is empty, returns value.
|
|
63
|
+
*/
|
|
64
|
+
function setNestedValue(obj, path, value) {
|
|
65
|
+
if (!path)
|
|
66
|
+
return value;
|
|
67
|
+
const segments = path.split('.');
|
|
68
|
+
const result = tryStructuredClone(obj);
|
|
69
|
+
let current = result ?? {};
|
|
70
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
71
|
+
const segment = segments[i];
|
|
72
|
+
const nextSegment = segments[i + 1];
|
|
73
|
+
const isNextIndex = !Number.isNaN(Number(nextSegment));
|
|
74
|
+
if (Array.isArray(current)) {
|
|
75
|
+
const index = Number(segment);
|
|
76
|
+
if (Number.isNaN(index))
|
|
77
|
+
break;
|
|
78
|
+
if (!current[index]) {
|
|
79
|
+
current[index] = isNextIndex ? [] : {};
|
|
80
|
+
}
|
|
81
|
+
current = current[index];
|
|
82
|
+
}
|
|
83
|
+
else if (typeof current === 'object' && current !== null) {
|
|
84
|
+
const currentObj = current;
|
|
85
|
+
if (!currentObj[segment]) {
|
|
86
|
+
currentObj[segment] = isNextIndex ? [] : {};
|
|
87
|
+
}
|
|
88
|
+
current = currentObj[segment];
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const lastSegment = segments[segments.length - 1];
|
|
92
|
+
if (Array.isArray(current)) {
|
|
93
|
+
const index = Number(lastSegment);
|
|
94
|
+
if (!Number.isNaN(index)) {
|
|
95
|
+
if (value === undefined) {
|
|
96
|
+
current.splice(index, 1);
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
current[index] = value;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
else if (typeof current === 'object' && current !== null) {
|
|
104
|
+
const currentObj = current;
|
|
105
|
+
if (value === undefined) {
|
|
106
|
+
delete currentObj[lastSegment];
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
currentObj[lastSegment] = value;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
/** Extract the root namespace from a full key (e.g. "ns.a.b" => "ns"). */
|
|
115
|
+
function getRootKey(key) {
|
|
116
|
+
return key.split('.')[0];
|
|
117
|
+
}
|
|
118
|
+
/** Extract the nested path from a full key (e.g. "ns.a.b" => "a.b"). */
|
|
119
|
+
function getPath(key) {
|
|
120
|
+
const segments = key.split('.');
|
|
121
|
+
return segments.slice(1).join('.');
|
|
122
|
+
}
|
|
123
|
+
// Helper function to notify listeners hierarchically
|
|
124
|
+
/** Notify exact, root, and affected child listeners for a given key change. */
|
|
125
|
+
function notifyListeners(key, oldValue, newValue, skipRoot = false, skipChildren = false) {
|
|
126
|
+
const rootKey = skipRoot ? null : key.split('.').slice(0, 2).join('.');
|
|
127
|
+
const keyPrefix = skipChildren ? null : key + '.';
|
|
128
|
+
// Single pass: collect listeners to notify
|
|
129
|
+
const listenersToNotify = new Set();
|
|
130
|
+
for (const [listenerKey, listenerSet] of listeners.entries()) {
|
|
131
|
+
if (listenerKey === key) {
|
|
132
|
+
// Exact key match
|
|
133
|
+
listenerSet.forEach(listener => listenersToNotify.add(listener));
|
|
134
|
+
}
|
|
135
|
+
else if (rootKey && listenerKey === rootKey) {
|
|
136
|
+
// Root key match
|
|
137
|
+
listenerSet.forEach(listener => listenersToNotify.add(listener));
|
|
138
|
+
}
|
|
139
|
+
else if (keyPrefix && listenerKey.startsWith(keyPrefix)) {
|
|
140
|
+
// Child key match - check if value actually changed
|
|
141
|
+
const childPath = listenerKey.substring(key.length + 1);
|
|
142
|
+
const oldChildValue = getNestedValue(oldValue, childPath);
|
|
143
|
+
const newChildValue = getNestedValue(newValue, childPath);
|
|
144
|
+
if (!isEqual(oldChildValue, newChildValue)) {
|
|
145
|
+
listenerSet.forEach(listener => listenersToNotify.add(listener));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
// Notify all collected listeners
|
|
150
|
+
listenersToNotify.forEach(listener => listener());
|
|
151
|
+
}
|
|
152
|
+
// BroadcastChannel for cross-tab synchronization
|
|
153
|
+
const broadcastChannel = typeof window !== 'undefined' ? new BroadcastChannel('godoxy-producer-consumer') : null;
|
|
154
|
+
/**
|
|
155
|
+
* Backing store providing in-memory data with localStorage persistence
|
|
156
|
+
* and cross-tab synchronization. All operations are namespaced at the root key
|
|
157
|
+
* (characters before the first dot).
|
|
158
|
+
*/
|
|
159
|
+
const store = {
|
|
160
|
+
has(key) {
|
|
161
|
+
const rootKey = getRootKey(key);
|
|
162
|
+
return (memoryStore.has(rootKey) ||
|
|
163
|
+
(typeof window !== 'undefined' && localStorageGet(rootKey) !== undefined));
|
|
164
|
+
},
|
|
165
|
+
get(key) {
|
|
166
|
+
const rootKey = getRootKey(key);
|
|
167
|
+
const path = getPath(key);
|
|
168
|
+
// Get root object from memory or localStorage
|
|
169
|
+
let rootValue;
|
|
170
|
+
if (memoryStore.has(rootKey)) {
|
|
171
|
+
rootValue = memoryStore.get(rootKey);
|
|
172
|
+
}
|
|
173
|
+
else if (typeof window !== 'undefined') {
|
|
174
|
+
rootValue = localStorageGet(rootKey);
|
|
175
|
+
if (rootValue !== undefined) {
|
|
176
|
+
memoryStore.set(rootKey, rootValue);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// If no path, return root value
|
|
180
|
+
if (!path)
|
|
181
|
+
return rootValue;
|
|
182
|
+
// Traverse to nested value
|
|
183
|
+
return getNestedValue(rootValue, path);
|
|
184
|
+
},
|
|
185
|
+
set(key, value, memoryOnly = false) {
|
|
186
|
+
const rootKey = getRootKey(key);
|
|
187
|
+
const path = getPath(key);
|
|
188
|
+
let rootValue;
|
|
189
|
+
if (!path) {
|
|
190
|
+
// Setting root value directly
|
|
191
|
+
rootValue = value;
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
// Setting nested value
|
|
195
|
+
const currentRoot = memoryStore.get(rootKey) ?? localStorageGet(rootKey) ?? {};
|
|
196
|
+
rootValue = setNestedValue(currentRoot, path, value);
|
|
197
|
+
}
|
|
198
|
+
// Update memory
|
|
199
|
+
if (rootValue === undefined) {
|
|
200
|
+
memoryStore.delete(rootKey);
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
memoryStore.set(rootKey, rootValue);
|
|
204
|
+
}
|
|
205
|
+
// Persist to localStorage (unless memoryOnly)
|
|
206
|
+
if (!memoryOnly && typeof window !== 'undefined') {
|
|
207
|
+
localStorageSet(rootKey, rootValue);
|
|
208
|
+
// Broadcast change to other tabs
|
|
209
|
+
if (broadcastChannel) {
|
|
210
|
+
broadcastChannel.postMessage({ type: 'set', key: rootKey, value: rootValue });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
delete(key, memoryOnly = false) {
|
|
215
|
+
const rootKey = getRootKey(key);
|
|
216
|
+
const path = getPath(key);
|
|
217
|
+
if (!path) {
|
|
218
|
+
// Deleting root key
|
|
219
|
+
memoryStore.delete(rootKey);
|
|
220
|
+
if (!memoryOnly && typeof window !== 'undefined') {
|
|
221
|
+
localStorageDelete(rootKey);
|
|
222
|
+
if (broadcastChannel) {
|
|
223
|
+
broadcastChannel.postMessage({ type: 'delete', key: rootKey });
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
// Deleting nested value
|
|
229
|
+
const currentRoot = memoryStore.get(rootKey) ?? localStorageGet(rootKey);
|
|
230
|
+
if (currentRoot !== undefined) {
|
|
231
|
+
const updatedRoot = setNestedValue(currentRoot, path, undefined);
|
|
232
|
+
memoryStore.set(rootKey, updatedRoot);
|
|
233
|
+
if (!memoryOnly && typeof window !== 'undefined') {
|
|
234
|
+
localStorageSet(rootKey, updatedRoot);
|
|
235
|
+
if (broadcastChannel) {
|
|
236
|
+
broadcastChannel.postMessage({ type: 'set', key: rootKey, value: updatedRoot });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
},
|
|
242
|
+
get size() {
|
|
243
|
+
return memoryStore.size;
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
/** Snapshot getter used by React's useSyncExternalStore. */
|
|
247
|
+
function getSnapshot(key) {
|
|
248
|
+
return store.get(key);
|
|
249
|
+
}
|
|
250
|
+
// Cross-tab synchronization: keep memoryStore in sync with BroadcastChannel events
|
|
251
|
+
if (broadcastChannel) {
|
|
252
|
+
broadcastChannel.addEventListener('message', event => {
|
|
253
|
+
const { type, key, value } = event.data;
|
|
254
|
+
if (!key)
|
|
255
|
+
return;
|
|
256
|
+
// Store old value before updating
|
|
257
|
+
const oldRootValue = memoryStore.get(key);
|
|
258
|
+
if (type === 'delete') {
|
|
259
|
+
memoryStore.delete(key);
|
|
260
|
+
}
|
|
261
|
+
else if (type === 'set') {
|
|
262
|
+
memoryStore.set(key, value);
|
|
263
|
+
}
|
|
264
|
+
// Notify all listeners that might be affected by this root key change
|
|
265
|
+
const newRootValue = type === 'delete' ? undefined : value;
|
|
266
|
+
for (const listenerKey of listeners.keys()) {
|
|
267
|
+
if (listenerKey === key) {
|
|
268
|
+
// Direct key match - notify with old and new values
|
|
269
|
+
notifyListeners(listenerKey, oldRootValue, newRootValue);
|
|
270
|
+
}
|
|
271
|
+
else if (listenerKey.startsWith(key + '.')) {
|
|
272
|
+
// Child key - check if its value actually changed
|
|
273
|
+
const childPath = listenerKey.substring(key.length + 1);
|
|
274
|
+
const oldChildValue = getNestedValue(oldRootValue, childPath);
|
|
275
|
+
const newChildValue = getNestedValue(newRootValue, childPath);
|
|
276
|
+
if (!isEqual(oldChildValue, newChildValue)) {
|
|
277
|
+
const childListeners = listeners.get(listenerKey);
|
|
278
|
+
childListeners?.forEach(listener => listener());
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
const listeners = new Map();
|
|
285
|
+
/** Subscribe to changes for a key; returns an unsubscribe function. */
|
|
286
|
+
function subscribe(key, listener) {
|
|
287
|
+
if (!listeners.has(key)) {
|
|
288
|
+
listeners.set(key, new Set());
|
|
289
|
+
}
|
|
290
|
+
listeners.get(key).add(listener);
|
|
291
|
+
return () => {
|
|
292
|
+
const keyListeners = listeners.get(key);
|
|
293
|
+
if (keyListeners) {
|
|
294
|
+
keyListeners.delete(listener);
|
|
295
|
+
if (keyListeners.size === 0) {
|
|
296
|
+
listeners.delete(key);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
/** Core mutation function that updates store and notifies listeners. */
|
|
302
|
+
function produce(key, value, skipUpdate = false, memoryOnly = false) {
|
|
303
|
+
const current = store.get(key);
|
|
304
|
+
if (value === undefined) {
|
|
305
|
+
skipUpdate = current === undefined;
|
|
306
|
+
store.delete(key, memoryOnly);
|
|
307
|
+
}
|
|
308
|
+
else {
|
|
309
|
+
if (isEqual(current, value))
|
|
310
|
+
return;
|
|
311
|
+
store.set(key, value, memoryOnly);
|
|
312
|
+
}
|
|
313
|
+
if (skipUpdate)
|
|
314
|
+
return;
|
|
315
|
+
// Notify listeners hierarchically with old and new values
|
|
316
|
+
notifyListeners(key, current, value);
|
|
317
|
+
}
|
|
318
|
+
/** React hook: subscribe to and read a namespaced path value. */
|
|
319
|
+
function useObject(key, path) {
|
|
320
|
+
const fullKey = joinPath(key, path);
|
|
321
|
+
const value = useSyncExternalStore(listener => subscribe(fullKey, listener), () => getSnapshot(fullKey), () => getSnapshot(fullKey));
|
|
322
|
+
return value;
|
|
323
|
+
}
|
|
324
|
+
/** React hook: subscribe to and read a namespaced path debounced value. */
|
|
325
|
+
function useDebounce(key, path, delay) {
|
|
326
|
+
const fullKey = joinPath(key, path);
|
|
327
|
+
const currentValue = useSyncExternalStore(listener => subscribe(fullKey, listener), () => getSnapshot(fullKey), () => getSnapshot(fullKey));
|
|
328
|
+
const [debouncedValue, setDebouncedValue] = useState(currentValue);
|
|
329
|
+
const timeoutRef = useRef(undefined);
|
|
330
|
+
useEffect(() => {
|
|
331
|
+
if (timeoutRef.current) {
|
|
332
|
+
clearTimeout(timeoutRef.current);
|
|
333
|
+
}
|
|
334
|
+
timeoutRef.current = setTimeout(() => {
|
|
335
|
+
if (!isEqual(debouncedValue, currentValue)) {
|
|
336
|
+
setDebouncedValue(currentValue);
|
|
337
|
+
}
|
|
338
|
+
}, delay);
|
|
339
|
+
return () => {
|
|
340
|
+
if (timeoutRef.current) {
|
|
341
|
+
clearTimeout(timeoutRef.current);
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
}, [currentValue, delay, debouncedValue]);
|
|
345
|
+
return debouncedValue;
|
|
346
|
+
}
|
|
347
|
+
/** Effectful subscription helper that calls onChange with the latest value. */
|
|
348
|
+
function useSubscribe(key, onChange) {
|
|
349
|
+
const onChangeRef = useRef(onChange);
|
|
350
|
+
useEffect(() => {
|
|
351
|
+
onChangeRef.current = onChange;
|
|
352
|
+
}, [onChange]);
|
|
353
|
+
useEffect(() => {
|
|
354
|
+
const unsubscribe = subscribe(key, () => {
|
|
355
|
+
const value = getSnapshot(key);
|
|
356
|
+
onChangeRef.current(value);
|
|
357
|
+
});
|
|
358
|
+
return unsubscribe;
|
|
359
|
+
}, [key]);
|
|
360
|
+
}
|
|
361
|
+
/** Set a leaf value under namespace.path, optionally skipping notifications. */
|
|
362
|
+
function setLeaf(key, path, value, skipUpdate = false, memoryOnly = false) {
|
|
363
|
+
const fullKey = joinPath(key, path);
|
|
364
|
+
produce(fullKey, value, skipUpdate, memoryOnly);
|
|
365
|
+
}
|
|
366
|
+
// Debug helpers (dev only)
|
|
367
|
+
/** Development-only debug helpers exposed on window.__pc_debug in development. */
|
|
368
|
+
const __pc_debug = {
|
|
369
|
+
getStoreSize: () => store.size,
|
|
370
|
+
getListenerSize: () => listeners.size,
|
|
371
|
+
getStore: () => memoryStore,
|
|
372
|
+
getStoreValue: (key) => memoryStore.get(key)
|
|
373
|
+
};
|
|
374
|
+
// Expose debug in browser for quick inspection during development
|
|
375
|
+
if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') {
|
|
376
|
+
;
|
|
377
|
+
window.__pc_debug = __pc_debug;
|
|
378
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { localStorageDelete, localStorageGet, localStorageSet };
|
|
2
|
+
/** Read from localStorage (JSON.parse) with prefix; undefined on SSR or error. */
|
|
3
|
+
declare function localStorageGet(key: string): unknown;
|
|
4
|
+
/** Write to localStorage (JSON.stringify); remove key when value is undefined. */
|
|
5
|
+
declare function localStorageSet(key: string, value: unknown): void;
|
|
6
|
+
/** Delete from localStorage with prefix. */
|
|
7
|
+
declare function localStorageDelete(key: string): void;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// localStorage operations
|
|
2
|
+
const STORAGE_PREFIX = 'juststore:';
|
|
3
|
+
export { localStorageDelete, localStorageGet, localStorageSet };
|
|
4
|
+
/** Read from localStorage (JSON.parse) with prefix; undefined on SSR or error. */
|
|
5
|
+
function localStorageGet(key) {
|
|
6
|
+
try {
|
|
7
|
+
if (typeof window === 'undefined')
|
|
8
|
+
return undefined;
|
|
9
|
+
const item = localStorage.getItem(`${STORAGE_PREFIX}${key}`);
|
|
10
|
+
return item ? JSON.parse(item) : undefined;
|
|
11
|
+
}
|
|
12
|
+
catch (e) {
|
|
13
|
+
console.error('Failed to get key from localStorage', key, e);
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** Write to localStorage (JSON.stringify); remove key when value is undefined. */
|
|
18
|
+
function localStorageSet(key, value) {
|
|
19
|
+
try {
|
|
20
|
+
if (typeof window === 'undefined')
|
|
21
|
+
return;
|
|
22
|
+
if (value === undefined) {
|
|
23
|
+
localStorage.removeItem(`${STORAGE_PREFIX}${key}`);
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
localStorage.setItem(`${STORAGE_PREFIX}${key}`, JSON.stringify(value));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
catch (e) {
|
|
30
|
+
console.error('Failed to set key in localStorage', key, value, e);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Delete from localStorage with prefix. */
|
|
34
|
+
function localStorageDelete(key) {
|
|
35
|
+
try {
|
|
36
|
+
if (typeof window === 'undefined')
|
|
37
|
+
return;
|
|
38
|
+
localStorage.removeItem(`${STORAGE_PREFIX}${key}`);
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
console.error('Failed to delete key from localStorage', key, e);
|
|
42
|
+
}
|
|
43
|
+
}
|
package/dist/memory.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { FieldValues } from './path';
|
|
2
|
+
import type { DeepProxy, State } from './types';
|
|
3
|
+
export { useMemoryStore, type MemoryStore };
|
|
4
|
+
/**
|
|
5
|
+
* A component local store with React bindings.
|
|
6
|
+
*
|
|
7
|
+
* - Dot-path addressing for nested values (e.g. "state.ui.theme").
|
|
8
|
+
* - Immutable partial updates with automatic object/array creation.
|
|
9
|
+
* - Fine-grained subscriptions built on useSyncExternalStore.
|
|
10
|
+
* - Type-safe paths using FieldPath.
|
|
11
|
+
* - Dynamic deep access via Proxy for ergonomic usage like `state.a.b.c.use()` and `state.a.b.c.set(v)`.
|
|
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]>;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Create a memory store. The returned object is a Proxy that
|
|
18
|
+
* exposes the base API (use/set/value/reset/...) and also allows deep, dynamic
|
|
19
|
+
* access: e.g. `store.user.profile.name.use()` or `store.todos.at(0).title.set('x')`.
|
|
20
|
+
*/
|
|
21
|
+
declare function useMemoryStore<T extends FieldValues>(defaultValue: T): MemoryStore<T>;
|
package/dist/memory.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { useId } from 'react';
|
|
2
|
+
import { createRootNode } from './node';
|
|
3
|
+
import { createStoreRoot } from './root';
|
|
4
|
+
export { useMemoryStore };
|
|
5
|
+
/**
|
|
6
|
+
* Create a memory store. The returned object is a Proxy that
|
|
7
|
+
* exposes the base API (use/set/value/reset/...) and also allows deep, dynamic
|
|
8
|
+
* access: e.g. `store.user.profile.name.use()` or `store.todos.at(0).title.set('x')`.
|
|
9
|
+
*/
|
|
10
|
+
function useMemoryStore(defaultValue) {
|
|
11
|
+
const memoryStoreId = useId();
|
|
12
|
+
const namespace = `memory:${memoryStoreId}`;
|
|
13
|
+
const storeApi = createStoreRoot(namespace, defaultValue, {
|
|
14
|
+
memoryOnly: true
|
|
15
|
+
});
|
|
16
|
+
return createRootNode(storeApi);
|
|
17
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Prettify, State } from './types';
|
|
2
|
+
export { createMixedState, type MixedState };
|
|
3
|
+
type MixedState<T extends readonly unknown[]> = Prettify<Pick<State<Readonly<T>>, 'value' | 'use' | 'Render' | 'Show'>>;
|
|
4
|
+
/**
|
|
5
|
+
* Creates a mixed state that combines multiple states into a tuple.
|
|
6
|
+
*
|
|
7
|
+
* If one of the states is changed, the mixed state will be updated.
|
|
8
|
+
*
|
|
9
|
+
* @param states - Array of states to combine
|
|
10
|
+
* @returns A new state that provides all values as a tuple
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* const mixedState = createMixedState(states.addLoading, states.copyLoading, state.agent)
|
|
14
|
+
*
|
|
15
|
+
* <mixedState.Render>
|
|
16
|
+
* {[addLoading, copyLoading, agent] => <SomeComponent />}
|
|
17
|
+
* </mixedState.Render>
|
|
18
|
+
*/
|
|
19
|
+
declare function createMixedState<T extends readonly unknown[]>(...states: {
|
|
20
|
+
[K in keyof T]: State<T[K]>;
|
|
21
|
+
}): MixedState<T>;
|