juststore 1.1.1 → 1.2.0
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/LICENSE +21 -661
- package/README.md +143 -166
- package/dist/atom.d.ts +1 -1
- package/dist/form.d.ts +1 -1
- package/dist/form.js +3 -1
- package/dist/impl.d.ts +1 -1
- package/dist/impl.js +1 -1
- package/dist/kv_store.d.ts +1 -1
- package/dist/kv_store.js +10 -2
- package/dist/memory.d.ts +1 -1
- package/dist/src/atom.d.ts +45 -0
- package/dist/src/atom.js +141 -0
- package/dist/src/form.d.ts +97 -0
- package/dist/src/form.js +176 -0
- package/dist/src/impl.d.ts +128 -0
- package/dist/src/impl.js +644 -0
- package/dist/src/index.d.ts +10 -0
- package/dist/src/index.js +7 -0
- package/dist/src/kv_store.d.ts +29 -0
- package/dist/src/kv_store.js +127 -0
- package/dist/src/local_storage.d.ts +7 -0
- package/dist/src/local_storage.js +43 -0
- package/dist/src/memory.d.ts +54 -0
- package/dist/src/memory.js +55 -0
- package/dist/src/mixed_state.d.ts +20 -0
- package/dist/src/mixed_state.js +45 -0
- package/dist/src/node.d.ts +41 -0
- package/dist/src/node.js +374 -0
- package/dist/src/path.d.ts +136 -0
- package/dist/src/path.js +26 -0
- package/dist/src/root.d.ts +23 -0
- package/dist/src/root.js +81 -0
- package/dist/src/stable_keys.d.ts +4 -0
- package/dist/src/stable_keys.js +31 -0
- package/dist/src/store.d.ts +42 -0
- package/dist/src/store.js +40 -0
- package/dist/src/types.d.ts +143 -0
- package/dist/src/types.js +1 -0
- package/dist/src/utils.d.ts +72 -0
- package/dist/src/utils.js +76 -0
- package/dist/utils.d.ts +1 -1
- package/dist/utils.js +1 -1
- package/package.json +61 -61
package/dist/src/impl.js
ADDED
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState, useSyncExternalStore, } from "react";
|
|
2
|
+
import rfcIsEqual from "react-fast-compare";
|
|
3
|
+
import { KVStore } from "./kv_store";
|
|
4
|
+
import { getExternalKeyOrder, getStableKeys, setExternalKeyOrder, } from "./stable_keys";
|
|
5
|
+
export { getNestedValue, getSnapshot, getStableKeys, isClass, isEqual, isRecord, joinPath, notifyListeners, produce, rename, setExternalKeyOrder, setLeaf, setNestedValue, subscribe, testReset, updateSnapshot, useCompute, useDebounce, useObject, };
|
|
6
|
+
const inMemStorage = new Map();
|
|
7
|
+
const listeners = new Map();
|
|
8
|
+
const descendantListenerKeysByPrefix = new Map();
|
|
9
|
+
const virtualRevisions = new Map();
|
|
10
|
+
const store = new KVStore({
|
|
11
|
+
inMemStorage,
|
|
12
|
+
memoryOnly: false,
|
|
13
|
+
});
|
|
14
|
+
const memoryStore = new KVStore({
|
|
15
|
+
inMemStorage,
|
|
16
|
+
memoryOnly: true,
|
|
17
|
+
});
|
|
18
|
+
function testReset() {
|
|
19
|
+
store.reset();
|
|
20
|
+
memoryStore.reset();
|
|
21
|
+
}
|
|
22
|
+
function isVirtualKey(key) {
|
|
23
|
+
return key.endsWith(".__juststore_keys") || key === "__juststore_keys";
|
|
24
|
+
}
|
|
25
|
+
// check if the value is a class instance
|
|
26
|
+
function isClass(value) {
|
|
27
|
+
if (value === null || value === undefined)
|
|
28
|
+
return false;
|
|
29
|
+
if (typeof value !== "object")
|
|
30
|
+
return false;
|
|
31
|
+
const proto = Object.getPrototypeOf(value);
|
|
32
|
+
if (!proto || proto === Object.prototype || proto === Array.prototype)
|
|
33
|
+
return false;
|
|
34
|
+
const descriptors = Object.getOwnPropertyDescriptors(proto);
|
|
35
|
+
for (const key in descriptors) {
|
|
36
|
+
if (descriptors[key]?.get)
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
function isRecord(value) {
|
|
42
|
+
if (value === null || value === undefined)
|
|
43
|
+
return false;
|
|
44
|
+
if (typeof value !== "object")
|
|
45
|
+
return false;
|
|
46
|
+
return !Array.isArray(value) && !isClass(value);
|
|
47
|
+
}
|
|
48
|
+
/** Compare two values for equality
|
|
49
|
+
* @description
|
|
50
|
+
* - react-fast-compare for non-class instances
|
|
51
|
+
* - reference equality for class instances
|
|
52
|
+
* @param a - The first value to compare
|
|
53
|
+
* @param b - The second value to compare
|
|
54
|
+
* @returns True if the values are equal, false otherwise
|
|
55
|
+
*/
|
|
56
|
+
function isEqual(a, b) {
|
|
57
|
+
if (a === b)
|
|
58
|
+
return true;
|
|
59
|
+
if (isClass(a) || isClass(b))
|
|
60
|
+
return a === b;
|
|
61
|
+
return rfcIsEqual(a, b);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Extracts the root namespace from a full key.
|
|
65
|
+
*
|
|
66
|
+
* @param key - Full key string
|
|
67
|
+
* @returns Namespace
|
|
68
|
+
* @example
|
|
69
|
+
* getNamespace('app.user.name') // 'app'
|
|
70
|
+
*/
|
|
71
|
+
function getNamespace(key) {
|
|
72
|
+
const index = key.indexOf(".");
|
|
73
|
+
if (index === -1)
|
|
74
|
+
return key;
|
|
75
|
+
return key.slice(0, index);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Joins a namespace and path into a full key string.
|
|
79
|
+
*
|
|
80
|
+
* @param namespace - The store namespace (root key)
|
|
81
|
+
* @param path - Optional dot-separated path within the namespace
|
|
82
|
+
* @returns Combined key string (e.g., "app.user.name")
|
|
83
|
+
*/
|
|
84
|
+
function joinPath(namespace, path) {
|
|
85
|
+
if (!path)
|
|
86
|
+
return namespace;
|
|
87
|
+
return `${namespace}.${path}`;
|
|
88
|
+
}
|
|
89
|
+
function joinChildKey(parent, child) {
|
|
90
|
+
return parent ? `${parent}.${child}` : child;
|
|
91
|
+
}
|
|
92
|
+
function getKeyPrefixes(key) {
|
|
93
|
+
const dot = key.indexOf(".");
|
|
94
|
+
if (dot === -1)
|
|
95
|
+
return [];
|
|
96
|
+
const [first, ...parts] = key.split(".");
|
|
97
|
+
if (parts.length === 0)
|
|
98
|
+
return [];
|
|
99
|
+
const prefixes = [];
|
|
100
|
+
let current = first;
|
|
101
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
102
|
+
current += `.${parts[i]}`;
|
|
103
|
+
prefixes.push(current);
|
|
104
|
+
}
|
|
105
|
+
prefixes.unshift(first);
|
|
106
|
+
return prefixes;
|
|
107
|
+
}
|
|
108
|
+
/** Snapshot getter used by React's useSyncExternalStore. */
|
|
109
|
+
function getSnapshot(key, memoryOnly) {
|
|
110
|
+
if (isVirtualKey(key)) {
|
|
111
|
+
return virtualRevisions.get(key) ?? 0;
|
|
112
|
+
}
|
|
113
|
+
if (memoryOnly) {
|
|
114
|
+
return memoryStore.get(key);
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
return store.get(key);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** Updates the snapshot of a key. */
|
|
121
|
+
function updateSnapshot(key, value, memoryOnly) {
|
|
122
|
+
if (memoryOnly) {
|
|
123
|
+
memoryStore.set(key, value);
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
store.set(key, value);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// Path traversal utilities
|
|
130
|
+
/** Get a nested value from an object/array using a dot-separated path. */
|
|
131
|
+
function getNestedValue(obj, path) {
|
|
132
|
+
if (!path)
|
|
133
|
+
return obj;
|
|
134
|
+
const segments = path.split(".");
|
|
135
|
+
let current = obj;
|
|
136
|
+
// Array indices must be explicit non-negative integers.
|
|
137
|
+
// IMPORTANT: treat empty string ("") as a *key*, not index 0.
|
|
138
|
+
// (Number('') === 0 would otherwise turn paths like `foo.bar.` into `foo.bar.0`.)
|
|
139
|
+
const parseArrayIndex = (segment) => {
|
|
140
|
+
if (!/^(0|[1-9]\d*)$/.test(segment))
|
|
141
|
+
return null;
|
|
142
|
+
return Number(segment);
|
|
143
|
+
};
|
|
144
|
+
for (const segment of segments) {
|
|
145
|
+
if (current === null || current === undefined)
|
|
146
|
+
return undefined;
|
|
147
|
+
if (typeof current !== "object")
|
|
148
|
+
return undefined;
|
|
149
|
+
if (Array.isArray(current)) {
|
|
150
|
+
const index = parseArrayIndex(segment);
|
|
151
|
+
if (index === null)
|
|
152
|
+
return undefined;
|
|
153
|
+
current = current[index];
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
current = current[segment];
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return current;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Immutably sets or deletes a nested value using a dot-separated path.
|
|
163
|
+
*
|
|
164
|
+
* Creates intermediate objects or arrays as needed based on whether the next
|
|
165
|
+
* path segment is numeric. When value is undefined, the key is deleted from
|
|
166
|
+
* objects or the index is spliced from arrays.
|
|
167
|
+
*
|
|
168
|
+
* @param obj - The root object to update
|
|
169
|
+
* @param path - Dot-separated path to the target location
|
|
170
|
+
* @param value - The value to set, or undefined to delete
|
|
171
|
+
* @returns A new root object with the change applied
|
|
172
|
+
*/
|
|
173
|
+
function setNestedValue(obj, path, value) {
|
|
174
|
+
if (!path)
|
|
175
|
+
return value;
|
|
176
|
+
const segments = path.split(".");
|
|
177
|
+
if (obj !== null && obj !== undefined && typeof obj !== "object") {
|
|
178
|
+
return obj;
|
|
179
|
+
}
|
|
180
|
+
// Array indices must be explicit non-negative integers.
|
|
181
|
+
// IMPORTANT: treat empty string ("") as a *key*, not index 0.
|
|
182
|
+
const parseArrayIndex = (segment) => {
|
|
183
|
+
if (!/^(0|[1-9]\d*)$/.test(segment))
|
|
184
|
+
return null;
|
|
185
|
+
return Number(segment);
|
|
186
|
+
};
|
|
187
|
+
const result = obj === null || obj === undefined
|
|
188
|
+
? {}
|
|
189
|
+
: Array.isArray(obj)
|
|
190
|
+
? [...obj]
|
|
191
|
+
: (() => {
|
|
192
|
+
const existing = obj;
|
|
193
|
+
const next = { ...existing };
|
|
194
|
+
const order = getExternalKeyOrder(existing);
|
|
195
|
+
if (order)
|
|
196
|
+
setExternalKeyOrder(next, order);
|
|
197
|
+
return next;
|
|
198
|
+
})();
|
|
199
|
+
let current = result;
|
|
200
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
201
|
+
const segment = segments[i];
|
|
202
|
+
const nextSegment = segments[i + 1];
|
|
203
|
+
const isNextIndex = parseArrayIndex(nextSegment) !== null;
|
|
204
|
+
if (Array.isArray(current)) {
|
|
205
|
+
const index = parseArrayIndex(segment);
|
|
206
|
+
if (index === null)
|
|
207
|
+
break;
|
|
208
|
+
const existing = current[index];
|
|
209
|
+
let next;
|
|
210
|
+
if (existing === null || existing === undefined) {
|
|
211
|
+
next = isNextIndex ? [] : {};
|
|
212
|
+
}
|
|
213
|
+
else if (typeof existing !== "object") {
|
|
214
|
+
next = isNextIndex ? [] : {};
|
|
215
|
+
}
|
|
216
|
+
else if (Array.isArray(existing)) {
|
|
217
|
+
next = [...existing];
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
next = { ...existing };
|
|
221
|
+
}
|
|
222
|
+
current[index] = next;
|
|
223
|
+
current = next;
|
|
224
|
+
}
|
|
225
|
+
else if (typeof current === "object" && current !== null) {
|
|
226
|
+
const currentObj = current;
|
|
227
|
+
const existing = currentObj[segment];
|
|
228
|
+
let next;
|
|
229
|
+
if (existing === null || existing === undefined) {
|
|
230
|
+
next = isNextIndex ? [] : {};
|
|
231
|
+
}
|
|
232
|
+
else if (typeof existing !== "object") {
|
|
233
|
+
next = isNextIndex ? [] : {};
|
|
234
|
+
}
|
|
235
|
+
else if (Array.isArray(existing)) {
|
|
236
|
+
next = [...existing];
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
const existingObj = existing;
|
|
240
|
+
next = { ...existingObj };
|
|
241
|
+
const order = getExternalKeyOrder(existingObj);
|
|
242
|
+
if (order)
|
|
243
|
+
setExternalKeyOrder(next, order);
|
|
244
|
+
}
|
|
245
|
+
currentObj[segment] = next;
|
|
246
|
+
current = next;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const lastSegment = segments[segments.length - 1];
|
|
250
|
+
if (Array.isArray(current)) {
|
|
251
|
+
const index = parseArrayIndex(lastSegment);
|
|
252
|
+
if (index !== null) {
|
|
253
|
+
if (value === undefined) {
|
|
254
|
+
current.splice(index, 1);
|
|
255
|
+
}
|
|
256
|
+
else {
|
|
257
|
+
current[index] = value;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
else if (typeof current === "object" && current !== null) {
|
|
262
|
+
const currentObj = current;
|
|
263
|
+
const hadKey = Object.hasOwn(currentObj, lastSegment);
|
|
264
|
+
if (value === undefined) {
|
|
265
|
+
delete currentObj[lastSegment];
|
|
266
|
+
if (hadKey) {
|
|
267
|
+
const order = getExternalKeyOrder(currentObj);
|
|
268
|
+
if (order)
|
|
269
|
+
setExternalKeyOrder(currentObj, order.filter((k) => k !== lastSegment));
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
currentObj[lastSegment] = value;
|
|
274
|
+
if (!hadKey) {
|
|
275
|
+
const order = getExternalKeyOrder(currentObj);
|
|
276
|
+
if (order)
|
|
277
|
+
setExternalKeyOrder(currentObj, [...order, lastSegment]);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return result;
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Notifies all relevant listeners when a value changes.
|
|
285
|
+
*
|
|
286
|
+
* Handles three types of listeners:
|
|
287
|
+
* 1. Exact match - listeners subscribed to the exact changed path
|
|
288
|
+
* 2. Root listeners - listeners on the namespace root (for full-store subscriptions)
|
|
289
|
+
* 3. Child listeners - listeners on nested paths that may be affected by the change
|
|
290
|
+
*
|
|
291
|
+
* Child listeners are only notified if their specific value actually changed,
|
|
292
|
+
* determined by deep equality comparison.
|
|
293
|
+
*/
|
|
294
|
+
function notifyListeners(key, oldValue, newValue, { skipRoot = false, skipChildren = false, forceNotify = false } = {}) {
|
|
295
|
+
// Keep `state.xxx.keys()` in sync: any mutation under a path can change the set of
|
|
296
|
+
// keys for that path (or its ancestors). Keys are represented as virtual nodes at
|
|
297
|
+
// `${path}.__juststore_keys`, so we bump those virtual nodes here.
|
|
298
|
+
//
|
|
299
|
+
// Important: avoid recursion when *we* are notifying a virtual key.
|
|
300
|
+
if (!isVirtualKey(key)) {
|
|
301
|
+
const paths = [...getKeyPrefixes(key), key];
|
|
302
|
+
for (const p of paths) {
|
|
303
|
+
const virtualKey = joinChildKey(p, "__juststore_keys");
|
|
304
|
+
const listenerSet = listeners.get(virtualKey);
|
|
305
|
+
if (listenerSet && listenerSet.size > 0) {
|
|
306
|
+
// Only notify the virtual key subscribers; the current call will handle
|
|
307
|
+
// ancestors/children for the real key.
|
|
308
|
+
notifyVirtualKey(virtualKey);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (skipRoot && skipChildren) {
|
|
313
|
+
if (!forceNotify && isEqual(oldValue, newValue)) {
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
// exact match only
|
|
317
|
+
const listenerSet = listeners.get(key);
|
|
318
|
+
if (listenerSet) {
|
|
319
|
+
listenerSet.forEach((listener) => {
|
|
320
|
+
listener();
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
// Exact key match
|
|
326
|
+
const exactSet = listeners.get(key);
|
|
327
|
+
if (exactSet) {
|
|
328
|
+
exactSet.forEach((listener) => {
|
|
329
|
+
listener();
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
// Ancestor keys match (including namespace root)
|
|
333
|
+
if (!skipRoot) {
|
|
334
|
+
const namespace = getNamespace(key);
|
|
335
|
+
if (namespace !== key) {
|
|
336
|
+
const rootSet = listeners.get(namespace);
|
|
337
|
+
if (rootSet) {
|
|
338
|
+
rootSet.forEach((listener) => {
|
|
339
|
+
listener();
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
// Also notify intermediate ancestors
|
|
344
|
+
const prefixes = getKeyPrefixes(key);
|
|
345
|
+
for (const prefix of prefixes) {
|
|
346
|
+
if (prefix === namespace)
|
|
347
|
+
continue; // Already handled
|
|
348
|
+
const prefixSet = listeners.get(prefix);
|
|
349
|
+
if (prefixSet) {
|
|
350
|
+
prefixSet.forEach((listener) => {
|
|
351
|
+
listener();
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
// Child key match - check if value actually changed
|
|
357
|
+
if (!skipChildren) {
|
|
358
|
+
const childKeys = descendantListenerKeysByPrefix.get(key);
|
|
359
|
+
if (childKeys) {
|
|
360
|
+
for (const childKey of childKeys) {
|
|
361
|
+
if (isVirtualKey(childKey)) {
|
|
362
|
+
const childPath = childKey.slice(key.length + 1);
|
|
363
|
+
const suffix = ".__juststore_keys";
|
|
364
|
+
const objectPath = childPath.endsWith(suffix)
|
|
365
|
+
? childPath.slice(0, -suffix.length)
|
|
366
|
+
: "";
|
|
367
|
+
const getKeys = (root) => {
|
|
368
|
+
const obj = objectPath ? getNestedValue(root, objectPath) : root;
|
|
369
|
+
return getStableKeys(obj);
|
|
370
|
+
};
|
|
371
|
+
const oldKeys = getKeys(oldValue);
|
|
372
|
+
const newKeys = getKeys(newValue);
|
|
373
|
+
if (forceNotify || !isEqual(oldKeys, newKeys)) {
|
|
374
|
+
notifyVirtualKey(childKey);
|
|
375
|
+
}
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
const childPath = childKey.slice(key.length + 1);
|
|
379
|
+
const oldChildValue = getNestedValue(oldValue, childPath);
|
|
380
|
+
const newChildValue = getNestedValue(newValue, childPath);
|
|
381
|
+
if (forceNotify || !isEqual(oldChildValue, newChildValue)) {
|
|
382
|
+
const childSet = listeners.get(childKey);
|
|
383
|
+
if (childSet) {
|
|
384
|
+
childSet.forEach((listener) => {
|
|
385
|
+
listener();
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
function notifyVirtualKey(key) {
|
|
394
|
+
virtualRevisions.set(key, (virtualRevisions.get(key) ?? 0) + 1);
|
|
395
|
+
notifyListeners(key, undefined, undefined, {
|
|
396
|
+
skipRoot: true,
|
|
397
|
+
skipChildren: true,
|
|
398
|
+
forceNotify: true,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Subscribes to changes for a specific key.
|
|
403
|
+
*
|
|
404
|
+
* @param key - The full key path to subscribe to
|
|
405
|
+
* @param listener - Callback invoked when the value changes
|
|
406
|
+
* @returns An unsubscribe function to remove the listener
|
|
407
|
+
*/
|
|
408
|
+
function subscribe(key, listener) {
|
|
409
|
+
if (!listeners.has(key)) {
|
|
410
|
+
listeners.set(key, new Set());
|
|
411
|
+
}
|
|
412
|
+
listeners.get(key)?.add(listener);
|
|
413
|
+
const prefixes = getKeyPrefixes(key);
|
|
414
|
+
for (const prefix of prefixes) {
|
|
415
|
+
if (!descendantListenerKeysByPrefix.has(prefix)) {
|
|
416
|
+
descendantListenerKeysByPrefix.set(prefix, new Set());
|
|
417
|
+
}
|
|
418
|
+
descendantListenerKeysByPrefix.get(prefix)?.add(key);
|
|
419
|
+
}
|
|
420
|
+
return () => {
|
|
421
|
+
const keyListeners = listeners.get(key);
|
|
422
|
+
if (keyListeners) {
|
|
423
|
+
keyListeners.delete(listener);
|
|
424
|
+
if (keyListeners.size === 0) {
|
|
425
|
+
listeners.delete(key);
|
|
426
|
+
for (const prefix of prefixes) {
|
|
427
|
+
const prefixKeys = descendantListenerKeysByPrefix.get(prefix);
|
|
428
|
+
if (prefixKeys) {
|
|
429
|
+
prefixKeys.delete(key);
|
|
430
|
+
if (prefixKeys.size === 0) {
|
|
431
|
+
descendantListenerKeysByPrefix.delete(prefix);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
function useCompute(namespace, path, fn, deps, memoryOnly = false) {
|
|
440
|
+
const fullPath = joinPath(namespace, path);
|
|
441
|
+
const fnRef = useRef(fn);
|
|
442
|
+
fnRef.current = fn;
|
|
443
|
+
const cacheRef = useRef(null);
|
|
444
|
+
const depsRef = useRef(deps);
|
|
445
|
+
// Invalidate cached compute when hook inputs change.
|
|
446
|
+
if (!isEqual(depsRef.current, deps)) {
|
|
447
|
+
depsRef.current = deps;
|
|
448
|
+
cacheRef.current = null;
|
|
449
|
+
}
|
|
450
|
+
const pathRef = useRef(fullPath);
|
|
451
|
+
if (pathRef.current !== fullPath) {
|
|
452
|
+
pathRef.current = fullPath;
|
|
453
|
+
cacheRef.current = null;
|
|
454
|
+
}
|
|
455
|
+
const subscribeToPath = useCallback((onStoreChange) => subscribe(fullPath, onStoreChange), [fullPath]);
|
|
456
|
+
const getComputedSnapshot = useCallback(() => {
|
|
457
|
+
const storeValue = getSnapshot(fullPath, memoryOnly);
|
|
458
|
+
if (cacheRef.current &&
|
|
459
|
+
Object.is(cacheRef.current.storeValue, storeValue)) {
|
|
460
|
+
// same store value, return the same computed value
|
|
461
|
+
return cacheRef.current.computed;
|
|
462
|
+
}
|
|
463
|
+
const computedNext = fnRef.current(storeValue);
|
|
464
|
+
// Important: even if storeValue changed, we should avoid forcing a re-render
|
|
465
|
+
// when the computed result is logically unchanged. `useSyncExternalStore`
|
|
466
|
+
// uses `Object.is` on the snapshot; returning the same reference will bail out.
|
|
467
|
+
if (cacheRef.current && isEqual(cacheRef.current.computed, computedNext)) {
|
|
468
|
+
cacheRef.current.storeValue = storeValue;
|
|
469
|
+
return cacheRef.current.computed;
|
|
470
|
+
}
|
|
471
|
+
cacheRef.current = { storeValue, computed: computedNext };
|
|
472
|
+
return computedNext;
|
|
473
|
+
}, [fullPath, memoryOnly]);
|
|
474
|
+
return useSyncExternalStore(subscribeToPath, getComputedSnapshot, getComputedSnapshot);
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* Core mutation function that updates the store and notifies listeners.
|
|
478
|
+
*
|
|
479
|
+
* Handles both setting and deleting values, with optimizations to skip
|
|
480
|
+
* unnecessary updates when the value hasn't changed.
|
|
481
|
+
*
|
|
482
|
+
* @param key - The full key path to update
|
|
483
|
+
* @param value - The new value, or undefined to delete
|
|
484
|
+
* @param skipUpdate - When true, skips notifying listeners
|
|
485
|
+
* @param memoryOnly - When true, skips localStorage persistence
|
|
486
|
+
*/
|
|
487
|
+
function produce(key, value, skipUpdate, memoryOnly) {
|
|
488
|
+
if (skipUpdate) {
|
|
489
|
+
updateSnapshot(key, value, memoryOnly);
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
const current = getSnapshot(key, memoryOnly);
|
|
493
|
+
if (isEqual(current, value))
|
|
494
|
+
return;
|
|
495
|
+
updateSnapshot(key, value, memoryOnly);
|
|
496
|
+
// Notify listeners hierarchically with old and new values
|
|
497
|
+
notifyListeners(key, current, value);
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* Renames a key in an object.
|
|
501
|
+
*
|
|
502
|
+
* It trigger updates to
|
|
503
|
+
*
|
|
504
|
+
* - listeners to `path` (key is updated)
|
|
505
|
+
* - listeners to `path.oldKey` (deleted)
|
|
506
|
+
* - listeners to `path.newKey` (created)
|
|
507
|
+
*
|
|
508
|
+
* @param path - The full key path to rename
|
|
509
|
+
* @param oldKey - The old key to rename
|
|
510
|
+
* @param newKey - The new key to rename to
|
|
511
|
+
*/
|
|
512
|
+
function rename(path, oldKey, newKey, memoryOnly) {
|
|
513
|
+
const current = getSnapshot(path, memoryOnly);
|
|
514
|
+
if (current === undefined ||
|
|
515
|
+
current === null ||
|
|
516
|
+
typeof current !== "object") {
|
|
517
|
+
// assign a new object with the new key
|
|
518
|
+
const next = { [newKey]: undefined };
|
|
519
|
+
updateSnapshot(path, next, memoryOnly);
|
|
520
|
+
setExternalKeyOrder(next, [newKey]);
|
|
521
|
+
notifyListeners(path, current, next);
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
const obj = current;
|
|
525
|
+
if (oldKey === newKey)
|
|
526
|
+
return;
|
|
527
|
+
if (!Object.hasOwn(obj, oldKey))
|
|
528
|
+
return;
|
|
529
|
+
const keyOrder = getStableKeys(obj);
|
|
530
|
+
const entries = [];
|
|
531
|
+
for (const key of keyOrder) {
|
|
532
|
+
if (!Object.hasOwn(obj, key))
|
|
533
|
+
continue;
|
|
534
|
+
if (key === oldKey) {
|
|
535
|
+
entries.push([newKey, obj[oldKey]]);
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
entries.push([key, obj[key]]);
|
|
539
|
+
}
|
|
540
|
+
const newObject = Object.fromEntries(entries);
|
|
541
|
+
updateSnapshot(path, newObject, memoryOnly);
|
|
542
|
+
setExternalKeyOrder(newObject, Array.from(new Set(entries.map(([k]) => k))));
|
|
543
|
+
notifyListeners(path, current, newObject);
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* React hook that subscribes to and reads a value at a path.
|
|
547
|
+
*
|
|
548
|
+
* Uses useSyncExternalStore for tear-free reads and automatic re-rendering
|
|
549
|
+
* when the subscribed value changes.
|
|
550
|
+
*
|
|
551
|
+
* @param key - The namespace or full key
|
|
552
|
+
* @param path - Optional path within the namespace
|
|
553
|
+
* @param memoryOnly - When true, skips localStorage persistence
|
|
554
|
+
* @returns The current value at the path, or undefined if not set
|
|
555
|
+
*/
|
|
556
|
+
function useObject(key, path, memoryOnly) {
|
|
557
|
+
const fullKey = joinPath(key, path);
|
|
558
|
+
const value = useSyncExternalStore((listener) => subscribe(fullKey, listener), () => getSnapshot(fullKey, memoryOnly), () => getSnapshot(fullKey, memoryOnly));
|
|
559
|
+
return value;
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* React hook that subscribes to a value with debounced updates.
|
|
563
|
+
*
|
|
564
|
+
* The returned value only updates after the specified delay has passed
|
|
565
|
+
* since the last change, useful for expensive operations like search.
|
|
566
|
+
*
|
|
567
|
+
* @param key - The namespace or full key
|
|
568
|
+
* @param path - Path within the namespace
|
|
569
|
+
* @param delay - Debounce delay in milliseconds
|
|
570
|
+
* @param memoryOnly - When true, skips localStorage persistence
|
|
571
|
+
* @returns The debounced value at the path
|
|
572
|
+
*/
|
|
573
|
+
function useDebounce(key, path, delay, memoryOnly) {
|
|
574
|
+
const fullKey = joinPath(key, path);
|
|
575
|
+
const currentValue = useSyncExternalStore((listener) => subscribe(fullKey, listener), () => getSnapshot(fullKey, memoryOnly), () => getSnapshot(fullKey, memoryOnly));
|
|
576
|
+
const [debouncedValue, setDebouncedValue] = useState(currentValue);
|
|
577
|
+
const timeoutRef = useRef(undefined);
|
|
578
|
+
useEffect(() => {
|
|
579
|
+
if (timeoutRef.current) {
|
|
580
|
+
clearTimeout(timeoutRef.current);
|
|
581
|
+
}
|
|
582
|
+
timeoutRef.current = setTimeout(() => {
|
|
583
|
+
if (!isEqual(debouncedValue, currentValue)) {
|
|
584
|
+
setDebouncedValue(currentValue);
|
|
585
|
+
}
|
|
586
|
+
}, delay);
|
|
587
|
+
return () => {
|
|
588
|
+
if (timeoutRef.current) {
|
|
589
|
+
clearTimeout(timeoutRef.current);
|
|
590
|
+
}
|
|
591
|
+
};
|
|
592
|
+
}, [currentValue, delay, debouncedValue]);
|
|
593
|
+
return debouncedValue;
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Sets a value at a specific path within a namespace.
|
|
597
|
+
*
|
|
598
|
+
* @param key - The namespace
|
|
599
|
+
* @param path - Path within the namespace
|
|
600
|
+
* @param value - The value to set, or undefined to delete
|
|
601
|
+
* @param skipUpdate - When true, skips notifying listeners
|
|
602
|
+
* @param memoryOnly - When true, skips localStorage persistence
|
|
603
|
+
*/
|
|
604
|
+
function setLeaf(key, path, value, skipUpdate = false, memoryOnly = false) {
|
|
605
|
+
const fullKey = joinPath(key, path);
|
|
606
|
+
produce(fullKey, value, skipUpdate, memoryOnly);
|
|
607
|
+
}
|
|
608
|
+
// BroadcastChannel for cross-tab synchronization
|
|
609
|
+
const broadcastChannel = typeof window !== "undefined" ? new BroadcastChannel("juststore") : null;
|
|
610
|
+
// Cross-tab synchronization: keep memoryStore in sync with BroadcastChannel events
|
|
611
|
+
if (broadcastChannel) {
|
|
612
|
+
store.setBroadcastChannel(broadcastChannel);
|
|
613
|
+
memoryStore.setBroadcastChannel(broadcastChannel);
|
|
614
|
+
broadcastChannel.addEventListener("message", (event) => {
|
|
615
|
+
const { type, key, value } = event.data;
|
|
616
|
+
if (!key)
|
|
617
|
+
return;
|
|
618
|
+
// Store old value before updating
|
|
619
|
+
const oldRootValue = memoryStore.get(key);
|
|
620
|
+
if (type === "delete") {
|
|
621
|
+
memoryStore.delete(key);
|
|
622
|
+
}
|
|
623
|
+
else if (type === "set") {
|
|
624
|
+
memoryStore.set(key, value);
|
|
625
|
+
}
|
|
626
|
+
// Notify all listeners that might be affected by this root key change
|
|
627
|
+
const newRootValue = type === "delete" ? undefined : value;
|
|
628
|
+
notifyListeners(key, oldRootValue, newRootValue);
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
// Debug helpers (dev only)
|
|
632
|
+
/** Development-only debug helpers exposed on window.__pc_debug in development. */
|
|
633
|
+
const __pc_debug = {
|
|
634
|
+
getStoreSize: () => store.size,
|
|
635
|
+
getListenerSize: () => listeners.size,
|
|
636
|
+
getStore: () => memoryStore,
|
|
637
|
+
getStoreValue: (key) => memoryStore.get(key),
|
|
638
|
+
getListeners: () => listeners,
|
|
639
|
+
};
|
|
640
|
+
// Expose debug in browser for quick inspection during development
|
|
641
|
+
if (typeof window !== "undefined" && process.env.NODE_ENV === "development") {
|
|
642
|
+
window.__pc_debug =
|
|
643
|
+
__pc_debug;
|
|
644
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { type Atom, createAtom } from "./atom";
|
|
2
|
+
export type * from "./form";
|
|
3
|
+
export { useForm } from "./form";
|
|
4
|
+
export { isEqual } from "./impl";
|
|
5
|
+
export { createMemoryStore, type MemoryStore, useMemoryStore } from "./memory";
|
|
6
|
+
export { createMixedState } from "./mixed_state";
|
|
7
|
+
export type * from "./path";
|
|
8
|
+
export { createStore, type Store } from "./store";
|
|
9
|
+
export type * from "./types";
|
|
10
|
+
export * from "./utils";
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { createAtom } from "./atom";
|
|
2
|
+
export { useForm } from "./form";
|
|
3
|
+
export { isEqual } from "./impl";
|
|
4
|
+
export { createMemoryStore, useMemoryStore } from "./memory";
|
|
5
|
+
export { createMixedState } from "./mixed_state";
|
|
6
|
+
export { createStore } from "./store";
|
|
7
|
+
export * from "./utils";
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { getNestedValue, setNestedValue } from "./impl";
|
|
2
|
+
export { getNestedValue, type KeyValueStore, KVStore, setNestedValue };
|
|
3
|
+
type KeyValueStore = {
|
|
4
|
+
getBroadcastChannel: () => BroadcastChannel | undefined;
|
|
5
|
+
setBroadcastChannel: (broadcastChannel: BroadcastChannel) => void;
|
|
6
|
+
get: (key: string) => unknown;
|
|
7
|
+
set: (key: string, value: unknown) => void;
|
|
8
|
+
delete: (key: string) => void;
|
|
9
|
+
reset: () => void;
|
|
10
|
+
readonly size: number;
|
|
11
|
+
};
|
|
12
|
+
type CreateKVStoreOptions = {
|
|
13
|
+
inMemStorage: Map<string, unknown>;
|
|
14
|
+
broadcastChannel?: BroadcastChannel;
|
|
15
|
+
memoryOnly: boolean;
|
|
16
|
+
};
|
|
17
|
+
declare class KVStore implements KeyValueStore {
|
|
18
|
+
private inMemStorage;
|
|
19
|
+
private broadcastChannel?;
|
|
20
|
+
private memoryOnly;
|
|
21
|
+
constructor(options: CreateKVStoreOptions);
|
|
22
|
+
getBroadcastChannel(): BroadcastChannel | undefined;
|
|
23
|
+
setBroadcastChannel(broadcastChannel: BroadcastChannel): void;
|
|
24
|
+
get(key: string): unknown;
|
|
25
|
+
set(key: string, value: unknown): void;
|
|
26
|
+
delete(key: string): void;
|
|
27
|
+
reset(): void;
|
|
28
|
+
get size(): number;
|
|
29
|
+
}
|