jotai-state-tree 0.1.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 -0
- package/README.md +168 -0
- package/dist/chunk-XXZK62DD.mjs +931 -0
- package/dist/index.d.mts +1109 -0
- package/dist/index.d.ts +1109 -0
- package/dist/index.js +3579 -0
- package/dist/index.mjs +2625 -0
- package/dist/react.d.mts +144 -0
- package/dist/react.d.ts +144 -0
- package/dist/react.js +1259 -0
- package/dist/react.mjs +372 -0
- package/package.json +77 -0
- package/src/__tests__/index.test.ts +1371 -0
- package/src/__tests__/memory.test.ts +681 -0
- package/src/__tests__/performance.test.ts +667 -0
- package/src/__tests__/react.react.test.tsx +811 -0
- package/src/__tests__/registry.test.ts +589 -0
- package/src/array.ts +335 -0
- package/src/compat.ts +294 -0
- package/src/index.ts +647 -0
- package/src/lifecycle.ts +580 -0
- package/src/map.ts +276 -0
- package/src/model.ts +832 -0
- package/src/primitives.ts +400 -0
- package/src/react.ts +626 -0
- package/src/registry.ts +741 -0
- package/src/tree.ts +1275 -0
- package/src/types.ts +520 -0
- package/src/undo.ts +566 -0
- package/src/utilities.ts +616 -0
package/src/tree.ts
ADDED
|
@@ -0,0 +1,1275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tree node management system
|
|
3
|
+
* Handles tree structure, parent/child relationships, and node lifecycle
|
|
4
|
+
*
|
|
5
|
+
* MEMORY MANAGEMENT:
|
|
6
|
+
* - Uses WeakRef for node registry to allow garbage collection
|
|
7
|
+
* - Uses FinalizationRegistry for automatic cleanup of abandoned nodes
|
|
8
|
+
* - Uses WeakMap where possible to avoid preventing GC
|
|
9
|
+
* - Properly cleans up all registries on node destruction
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { atom, createStore, type WritableAtom } from "jotai";
|
|
13
|
+
import type {
|
|
14
|
+
IStateTreeNode,
|
|
15
|
+
IType,
|
|
16
|
+
IAnyType,
|
|
17
|
+
IAnyModelType,
|
|
18
|
+
IJsonPatch,
|
|
19
|
+
IReversibleJsonPatch,
|
|
20
|
+
IDisposer,
|
|
21
|
+
} from "./types";
|
|
22
|
+
|
|
23
|
+
// Re-export IDisposer for convenience
|
|
24
|
+
export type { IDisposer };
|
|
25
|
+
|
|
26
|
+
// ============================================================================
|
|
27
|
+
// Global Store & Registry
|
|
28
|
+
// ============================================================================
|
|
29
|
+
|
|
30
|
+
/** Global Jotai store instance */
|
|
31
|
+
let globalStore = createStore();
|
|
32
|
+
|
|
33
|
+
/** Get the global store */
|
|
34
|
+
export function getGlobalStore() {
|
|
35
|
+
return globalStore;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Set a custom global store (useful for testing) */
|
|
39
|
+
export function setGlobalStore(store: ReturnType<typeof createStore>) {
|
|
40
|
+
globalStore = store;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Reset the global store (useful for testing) */
|
|
44
|
+
export function resetGlobalStore() {
|
|
45
|
+
globalStore = createStore();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ============================================================================
|
|
49
|
+
// Node Registry with Weak References
|
|
50
|
+
// ============================================================================
|
|
51
|
+
|
|
52
|
+
interface NodeEntry {
|
|
53
|
+
node: WeakRef<StateTreeNode>;
|
|
54
|
+
instance: WeakRef<object> | null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Registry mapping node IDs to their entries using WeakRef
|
|
59
|
+
* This allows nodes to be garbage collected when no longer referenced
|
|
60
|
+
*/
|
|
61
|
+
const nodeRegistry = new Map<string, NodeEntry>();
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* FinalizationRegistry for automatic cleanup when nodes are garbage collected
|
|
65
|
+
* This ensures the nodeRegistry doesn't accumulate stale entries
|
|
66
|
+
*/
|
|
67
|
+
const nodeFinalizationRegistry = new FinalizationRegistry((nodeId: string) => {
|
|
68
|
+
nodeRegistry.delete(nodeId);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Registry for identifier lookups (type -> identifier -> WeakRef<node>)
|
|
73
|
+
* Uses WeakRef to allow garbage collection of nodes
|
|
74
|
+
*/
|
|
75
|
+
const identifierRegistry = new Map<
|
|
76
|
+
string,
|
|
77
|
+
Map<string | number, WeakRef<StateTreeNode>>
|
|
78
|
+
>();
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* FinalizationRegistry for identifier cleanup
|
|
82
|
+
*/
|
|
83
|
+
const identifierFinalizationRegistry = new FinalizationRegistry(
|
|
84
|
+
(info: { typeName: string; identifier: string | number }) => {
|
|
85
|
+
const typeMap = identifierRegistry.get(info.typeName);
|
|
86
|
+
if (typeMap) {
|
|
87
|
+
typeMap.delete(info.identifier);
|
|
88
|
+
// Clean up empty type maps
|
|
89
|
+
if (typeMap.size === 0) {
|
|
90
|
+
identifierRegistry.delete(info.typeName);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
/** Counter for generating unique node IDs */
|
|
97
|
+
let nodeIdCounter = 0;
|
|
98
|
+
|
|
99
|
+
function generateNodeId(): string {
|
|
100
|
+
return `node_${++nodeIdCounter}_${Date.now().toString(36)}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ============================================================================
|
|
104
|
+
// Lifecycle Change Listeners (for useIsAlive and other subscribers)
|
|
105
|
+
// ============================================================================
|
|
106
|
+
|
|
107
|
+
/** WeakMap to store lifecycle listeners per node - allows GC of nodes */
|
|
108
|
+
const lifecycleListeners = new WeakMap<
|
|
109
|
+
StateTreeNode,
|
|
110
|
+
Set<(isAlive: boolean) => void>
|
|
111
|
+
>();
|
|
112
|
+
|
|
113
|
+
/** Subscribe to lifecycle changes of a node */
|
|
114
|
+
export function onLifecycleChange(
|
|
115
|
+
node: StateTreeNode,
|
|
116
|
+
listener: (isAlive: boolean) => void,
|
|
117
|
+
): IDisposer {
|
|
118
|
+
let listeners = lifecycleListeners.get(node);
|
|
119
|
+
if (!listeners) {
|
|
120
|
+
listeners = new Set();
|
|
121
|
+
lifecycleListeners.set(node, listeners);
|
|
122
|
+
}
|
|
123
|
+
listeners.add(listener);
|
|
124
|
+
return () => {
|
|
125
|
+
listeners?.delete(listener);
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Notify lifecycle listeners */
|
|
130
|
+
function notifyLifecycleChange(node: StateTreeNode, isAlive: boolean) {
|
|
131
|
+
const listeners = lifecycleListeners.get(node);
|
|
132
|
+
if (listeners) {
|
|
133
|
+
listeners.forEach((listener) => listener(isAlive));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ============================================================================
|
|
138
|
+
// State Tree Node Implementation
|
|
139
|
+
// ============================================================================
|
|
140
|
+
|
|
141
|
+
export class StateTreeNode implements IStateTreeNode {
|
|
142
|
+
readonly $id: string;
|
|
143
|
+
readonly $type: IAnyType;
|
|
144
|
+
$parent: StateTreeNode | null = null;
|
|
145
|
+
$path: string = "";
|
|
146
|
+
$env: unknown;
|
|
147
|
+
$isAlive: boolean = true;
|
|
148
|
+
|
|
149
|
+
/** Child nodes - uses Map but children are explicitly destroyed */
|
|
150
|
+
private children = new Map<string, StateTreeNode>();
|
|
151
|
+
|
|
152
|
+
/** Atom storing the raw value/snapshot */
|
|
153
|
+
valueAtom: WritableAtom<unknown, [unknown], void>;
|
|
154
|
+
|
|
155
|
+
/** Snapshot listeners */
|
|
156
|
+
private snapshotListeners = new Set<(snapshot: unknown) => void>();
|
|
157
|
+
|
|
158
|
+
/** Patch listeners */
|
|
159
|
+
private patchListeners = new Set<
|
|
160
|
+
(patch: IJsonPatch, reversePatch: IReversibleJsonPatch) => void
|
|
161
|
+
>();
|
|
162
|
+
|
|
163
|
+
/** Volatile state (non-serialized) */
|
|
164
|
+
volatileState: Record<string, unknown> = {};
|
|
165
|
+
|
|
166
|
+
/** Pre/post process snapshot functions */
|
|
167
|
+
preProcessor?: (snapshot: unknown) => unknown;
|
|
168
|
+
postProcessor?: (snapshot: unknown) => unknown;
|
|
169
|
+
|
|
170
|
+
/** Identifier value if this node has one */
|
|
171
|
+
identifierValue?: string | number;
|
|
172
|
+
|
|
173
|
+
/** Type name for identifier registry */
|
|
174
|
+
identifierTypeName?: string;
|
|
175
|
+
|
|
176
|
+
constructor(
|
|
177
|
+
type: IAnyType,
|
|
178
|
+
initialValue: unknown,
|
|
179
|
+
env?: unknown,
|
|
180
|
+
parent?: StateTreeNode,
|
|
181
|
+
pathSegment?: string,
|
|
182
|
+
) {
|
|
183
|
+
this.$id = generateNodeId();
|
|
184
|
+
this.$type = type;
|
|
185
|
+
this.$env = env ?? parent?.$env;
|
|
186
|
+
this.$parent = parent ?? null;
|
|
187
|
+
this.$path = parent ? `${parent.$path}/${pathSegment}` : "";
|
|
188
|
+
|
|
189
|
+
// Create the value atom
|
|
190
|
+
this.valueAtom = atom(initialValue);
|
|
191
|
+
|
|
192
|
+
// Register this node with WeakRef
|
|
193
|
+
nodeRegistry.set(this.$id, { node: new WeakRef(this), instance: null });
|
|
194
|
+
|
|
195
|
+
// Register for automatic cleanup on GC
|
|
196
|
+
nodeFinalizationRegistry.register(this, this.$id, this);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Set the instance reference */
|
|
200
|
+
setInstance(instance: unknown) {
|
|
201
|
+
const entry = nodeRegistry.get(this.$id);
|
|
202
|
+
if (entry && instance && typeof instance === "object") {
|
|
203
|
+
entry.instance = new WeakRef(instance as object);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Get the instance */
|
|
208
|
+
getInstance(): unknown {
|
|
209
|
+
const entry = nodeRegistry.get(this.$id);
|
|
210
|
+
return entry?.instance?.deref() ?? null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Get current value from atom */
|
|
214
|
+
getValue(): unknown {
|
|
215
|
+
return globalStore.get(this.valueAtom);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Set value on atom */
|
|
219
|
+
setValue(value: unknown) {
|
|
220
|
+
if (!this.$isAlive) {
|
|
221
|
+
throw new Error(
|
|
222
|
+
`[jotai-state-tree] Cannot modify a node that is no longer part of the state tree. ` +
|
|
223
|
+
`(Node type: '${this.$type.name}', Path: '${this.$path}')`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const oldValue = this.getValue();
|
|
228
|
+
globalStore.set(this.valueAtom, value);
|
|
229
|
+
|
|
230
|
+
// Notify patch listeners
|
|
231
|
+
this.notifyPatch(
|
|
232
|
+
{ op: "replace", path: this.$path, value },
|
|
233
|
+
{ op: "replace", path: this.$path, value: oldValue, oldValue },
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
// Notify snapshot listeners (bubble up to root)
|
|
237
|
+
this.notifySnapshotChange();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Add a child node */
|
|
241
|
+
addChild(key: string, child: StateTreeNode) {
|
|
242
|
+
child.$parent = this;
|
|
243
|
+
const newPath = `${this.$path}/${key}`;
|
|
244
|
+
this.updatePathRecursively(child, newPath);
|
|
245
|
+
child.$env = child.$env ?? this.$env;
|
|
246
|
+
this.children.set(key, child);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Recursively update the path of a node and all its children */
|
|
250
|
+
private updatePathRecursively(node: StateTreeNode, newPath: string) {
|
|
251
|
+
node.$path = newPath;
|
|
252
|
+
|
|
253
|
+
// Update all children's paths
|
|
254
|
+
for (const [childKey, childNode] of node.children) {
|
|
255
|
+
const childNewPath = `${newPath}/${childKey}`;
|
|
256
|
+
this.updatePathRecursively(childNode, childNewPath);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Remove a child node */
|
|
261
|
+
removeChild(key: string) {
|
|
262
|
+
const child = this.children.get(key);
|
|
263
|
+
if (child) {
|
|
264
|
+
child.destroy();
|
|
265
|
+
this.children.delete(key);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Get a child node */
|
|
270
|
+
getChild(key: string): StateTreeNode | undefined {
|
|
271
|
+
return this.children.get(key);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Get all children */
|
|
275
|
+
getChildren(): Map<string, StateTreeNode> {
|
|
276
|
+
return this.children;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Register identifier */
|
|
280
|
+
registerIdentifier(typeName: string, identifier: string | number) {
|
|
281
|
+
this.identifierTypeName = typeName;
|
|
282
|
+
this.identifierValue = identifier;
|
|
283
|
+
|
|
284
|
+
let typeMap = identifierRegistry.get(typeName);
|
|
285
|
+
if (!typeMap) {
|
|
286
|
+
typeMap = new Map();
|
|
287
|
+
identifierRegistry.set(typeName, typeMap);
|
|
288
|
+
}
|
|
289
|
+
typeMap.set(identifier, new WeakRef(this));
|
|
290
|
+
|
|
291
|
+
// Register for automatic cleanup on GC
|
|
292
|
+
identifierFinalizationRegistry.register(
|
|
293
|
+
this,
|
|
294
|
+
{ typeName, identifier },
|
|
295
|
+
this,
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Unregister identifier */
|
|
300
|
+
unregisterIdentifier() {
|
|
301
|
+
if (
|
|
302
|
+
this.identifierTypeName !== undefined &&
|
|
303
|
+
this.identifierValue !== undefined
|
|
304
|
+
) {
|
|
305
|
+
const typeMap = identifierRegistry.get(this.identifierTypeName);
|
|
306
|
+
if (typeMap) {
|
|
307
|
+
typeMap.delete(this.identifierValue);
|
|
308
|
+
// Clean up empty type maps to prevent accumulation
|
|
309
|
+
if (typeMap.size === 0) {
|
|
310
|
+
identifierRegistry.delete(this.identifierTypeName);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
// Unregister from finalization registry
|
|
314
|
+
identifierFinalizationRegistry.unregister(this);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Subscribe to snapshot changes */
|
|
319
|
+
onSnapshot(listener: (snapshot: unknown) => void): IDisposer {
|
|
320
|
+
this.snapshotListeners.add(listener);
|
|
321
|
+
return () => {
|
|
322
|
+
this.snapshotListeners.delete(listener);
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Subscribe to patches */
|
|
327
|
+
onPatch(
|
|
328
|
+
listener: (patch: IJsonPatch, reversePatch: IReversibleJsonPatch) => void,
|
|
329
|
+
): IDisposer {
|
|
330
|
+
this.patchListeners.add(listener);
|
|
331
|
+
return () => {
|
|
332
|
+
this.patchListeners.delete(listener);
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Notify patch listeners */
|
|
337
|
+
private notifyPatch(patch: IJsonPatch, reversePatch: IReversibleJsonPatch) {
|
|
338
|
+
this.patchListeners.forEach((listener) => listener(patch, reversePatch));
|
|
339
|
+
// Bubble up to parent
|
|
340
|
+
if (this.$parent) {
|
|
341
|
+
this.$parent.notifyPatch(patch, reversePatch);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Notify snapshot listeners */
|
|
346
|
+
private notifySnapshotChange() {
|
|
347
|
+
// Get the root and notify its listeners
|
|
348
|
+
const root = this.getRoot();
|
|
349
|
+
const snapshot = getSnapshotFromNode(root);
|
|
350
|
+
root.snapshotListeners.forEach((listener) => listener(snapshot));
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Notify about a property change (for use by model proxy) */
|
|
354
|
+
notifyPropertyChange(propName: string, newValue: unknown, oldValue: unknown) {
|
|
355
|
+
const path = this.$path ? `${this.$path}/${propName}` : `/${propName}`;
|
|
356
|
+
this.notifyPatch(
|
|
357
|
+
{ op: "replace", path, value: newValue },
|
|
358
|
+
{ op: "replace", path, value: oldValue, oldValue },
|
|
359
|
+
);
|
|
360
|
+
this.notifySnapshotChange();
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Get root node */
|
|
364
|
+
getRoot(): StateTreeNode {
|
|
365
|
+
let node: StateTreeNode = this;
|
|
366
|
+
while (node.$parent) {
|
|
367
|
+
node = node.$parent;
|
|
368
|
+
}
|
|
369
|
+
return node;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Destroy this node and all children */
|
|
373
|
+
destroy() {
|
|
374
|
+
if (!this.$isAlive) return;
|
|
375
|
+
|
|
376
|
+
// Destroy children first
|
|
377
|
+
this.children.forEach((child) => child.destroy());
|
|
378
|
+
this.children.clear();
|
|
379
|
+
|
|
380
|
+
// Unregister identifier
|
|
381
|
+
this.unregisterIdentifier();
|
|
382
|
+
|
|
383
|
+
// Mark as dead
|
|
384
|
+
this.$isAlive = false;
|
|
385
|
+
|
|
386
|
+
// Notify lifecycle listeners
|
|
387
|
+
notifyLifecycleChange(this, false);
|
|
388
|
+
|
|
389
|
+
// Remove from node registry
|
|
390
|
+
nodeRegistry.delete(this.$id);
|
|
391
|
+
|
|
392
|
+
// Unregister from finalization registry (already destroyed, don't need GC cleanup)
|
|
393
|
+
nodeFinalizationRegistry.unregister(this);
|
|
394
|
+
|
|
395
|
+
// Clear listeners
|
|
396
|
+
this.snapshotListeners.clear();
|
|
397
|
+
this.patchListeners.clear();
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Detach from parent */
|
|
401
|
+
detach() {
|
|
402
|
+
if (this.$parent) {
|
|
403
|
+
// Find our key in parent's children
|
|
404
|
+
for (const [key, child] of this.$parent.children) {
|
|
405
|
+
if (child === this) {
|
|
406
|
+
this.$parent.children.delete(key);
|
|
407
|
+
break;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
this.$parent = null;
|
|
411
|
+
this.$path = "";
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ============================================================================
|
|
417
|
+
// Node Utilities
|
|
418
|
+
// ============================================================================
|
|
419
|
+
|
|
420
|
+
/** Symbol to access the tree node from an instance */
|
|
421
|
+
export const $treenode = Symbol.for("jotai-state-tree-node");
|
|
422
|
+
|
|
423
|
+
/** Get the tree node from an instance */
|
|
424
|
+
export function getStateTreeNode(instance: unknown): StateTreeNode {
|
|
425
|
+
if (instance && typeof instance === "object" && $treenode in instance) {
|
|
426
|
+
return (instance as Record<typeof $treenode, StateTreeNode>)[$treenode];
|
|
427
|
+
}
|
|
428
|
+
throw new Error("[jotai-state-tree] Value is not a state tree node");
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Check if value has a tree node */
|
|
432
|
+
export function hasStateTreeNode(instance: unknown): boolean {
|
|
433
|
+
return (
|
|
434
|
+
instance !== null && typeof instance === "object" && $treenode in instance
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Get snapshot from a node */
|
|
439
|
+
export function getSnapshotFromNode(node: StateTreeNode): unknown {
|
|
440
|
+
const type = node.$type;
|
|
441
|
+
const value = node.getValue();
|
|
442
|
+
|
|
443
|
+
// Handle based on type kind
|
|
444
|
+
if (type._kind === "model") {
|
|
445
|
+
const snapshot: Record<string, unknown> = {};
|
|
446
|
+
const children = node.getChildren();
|
|
447
|
+
|
|
448
|
+
for (const [key, childNode] of children) {
|
|
449
|
+
snapshot[key] = getSnapshotFromNode(childNode);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Apply post processor if exists
|
|
453
|
+
if (node.postProcessor) {
|
|
454
|
+
return node.postProcessor(snapshot);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
return snapshot;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (type._kind === "array") {
|
|
461
|
+
const arr = value as unknown[];
|
|
462
|
+
return arr.map((_, index) => {
|
|
463
|
+
const childNode = node.getChild(String(index));
|
|
464
|
+
return childNode ? getSnapshotFromNode(childNode) : arr[index];
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (type._kind === "map") {
|
|
469
|
+
const snapshot: Record<string, unknown> = {};
|
|
470
|
+
const children = node.getChildren();
|
|
471
|
+
for (const [key, childNode] of children) {
|
|
472
|
+
snapshot[key] = getSnapshotFromNode(childNode);
|
|
473
|
+
}
|
|
474
|
+
return snapshot;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if (type._kind === "reference") {
|
|
478
|
+
// Return the identifier, not the resolved value
|
|
479
|
+
return node.identifierValue ?? value;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// For primitives and frozen, return the value directly
|
|
483
|
+
return value;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** Apply snapshot to a node */
|
|
487
|
+
export function applySnapshotToNode(node: StateTreeNode, snapshot: unknown) {
|
|
488
|
+
if (!node.$isAlive) {
|
|
489
|
+
throw new Error("[jotai-state-tree] Cannot apply snapshot to a dead node");
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const type = node.$type;
|
|
493
|
+
|
|
494
|
+
// Apply pre processor if exists
|
|
495
|
+
if (node.preProcessor) {
|
|
496
|
+
snapshot = node.preProcessor(snapshot);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (
|
|
500
|
+
type._kind === "model" &&
|
|
501
|
+
typeof snapshot === "object" &&
|
|
502
|
+
snapshot !== null
|
|
503
|
+
) {
|
|
504
|
+
const snapshotObj = snapshot as Record<string, unknown>;
|
|
505
|
+
const children = node.getChildren();
|
|
506
|
+
|
|
507
|
+
for (const [key, childNode] of children) {
|
|
508
|
+
if (key in snapshotObj) {
|
|
509
|
+
applySnapshotToNode(childNode, snapshotObj[key]);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
} else if (type._kind === "array" && Array.isArray(snapshot)) {
|
|
513
|
+
// For arrays, we need to reconcile
|
|
514
|
+
node.setValue(snapshot);
|
|
515
|
+
} else if (
|
|
516
|
+
type._kind === "map" &&
|
|
517
|
+
typeof snapshot === "object" &&
|
|
518
|
+
snapshot !== null
|
|
519
|
+
) {
|
|
520
|
+
// For maps, replace all entries
|
|
521
|
+
node.setValue(snapshot);
|
|
522
|
+
} else {
|
|
523
|
+
// For primitives
|
|
524
|
+
node.setValue(snapshot);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** Look up a node by identifier */
|
|
529
|
+
export function resolveIdentifier(
|
|
530
|
+
typeName: string,
|
|
531
|
+
identifier: string | number,
|
|
532
|
+
): StateTreeNode | undefined {
|
|
533
|
+
const weakRef = identifierRegistry.get(typeName)?.get(identifier);
|
|
534
|
+
return weakRef?.deref();
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** Get all nodes of a type */
|
|
538
|
+
export function getNodesOfType(typeName: string): StateTreeNode[] {
|
|
539
|
+
const typeMap = identifierRegistry.get(typeName);
|
|
540
|
+
if (!typeMap) return [];
|
|
541
|
+
|
|
542
|
+
const nodes: StateTreeNode[] = [];
|
|
543
|
+
for (const weakRef of typeMap.values()) {
|
|
544
|
+
const node = weakRef.deref();
|
|
545
|
+
if (node) {
|
|
546
|
+
nodes.push(node);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
return nodes;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// ============================================================================
|
|
553
|
+
// Registry Statistics (for testing and debugging)
|
|
554
|
+
// ============================================================================
|
|
555
|
+
|
|
556
|
+
/** Get statistics about the registries - useful for debugging memory issues */
|
|
557
|
+
export function getRegistryStats(): {
|
|
558
|
+
nodeRegistrySize: number;
|
|
559
|
+
identifierRegistrySize: number;
|
|
560
|
+
identifierTypeCount: number;
|
|
561
|
+
liveNodeCount: number;
|
|
562
|
+
staleNodeCount: number;
|
|
563
|
+
} {
|
|
564
|
+
let liveNodeCount = 0;
|
|
565
|
+
let staleNodeCount = 0;
|
|
566
|
+
|
|
567
|
+
for (const entry of nodeRegistry.values()) {
|
|
568
|
+
const node = entry.node.deref();
|
|
569
|
+
// A node is "live" if it exists AND $isAlive is true
|
|
570
|
+
if (node && node.$isAlive) {
|
|
571
|
+
liveNodeCount++;
|
|
572
|
+
} else {
|
|
573
|
+
staleNodeCount++;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
let identifierCount = 0;
|
|
578
|
+
for (const typeMap of identifierRegistry.values()) {
|
|
579
|
+
// Only count identifiers that point to live nodes
|
|
580
|
+
for (const weakRef of typeMap.values()) {
|
|
581
|
+
const node = weakRef.deref();
|
|
582
|
+
if (node && node.$isAlive) {
|
|
583
|
+
identifierCount++;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
return {
|
|
589
|
+
nodeRegistrySize: nodeRegistry.size,
|
|
590
|
+
identifierRegistrySize: identifierCount,
|
|
591
|
+
identifierTypeCount: identifierRegistry.size,
|
|
592
|
+
liveNodeCount,
|
|
593
|
+
staleNodeCount,
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** Clean up stale entries from registries - call periodically if needed */
|
|
598
|
+
export function cleanupStaleEntries(): number {
|
|
599
|
+
let cleaned = 0;
|
|
600
|
+
|
|
601
|
+
// Clean stale node entries
|
|
602
|
+
for (const [id, entry] of nodeRegistry.entries()) {
|
|
603
|
+
if (!entry.node.deref()) {
|
|
604
|
+
nodeRegistry.delete(id);
|
|
605
|
+
cleaned++;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Clean stale identifier entries
|
|
610
|
+
for (const [typeName, typeMap] of identifierRegistry.entries()) {
|
|
611
|
+
for (const [identifier, weakRef] of typeMap.entries()) {
|
|
612
|
+
if (!weakRef.deref()) {
|
|
613
|
+
typeMap.delete(identifier);
|
|
614
|
+
cleaned++;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
if (typeMap.size === 0) {
|
|
618
|
+
identifierRegistry.delete(typeName);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
return cleaned;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** Clear all registries - useful for testing */
|
|
626
|
+
export function clearAllRegistries(): void {
|
|
627
|
+
// First, mark all nodes as dead before clearing
|
|
628
|
+
for (const entry of nodeRegistry.values()) {
|
|
629
|
+
const node = entry.node.deref();
|
|
630
|
+
if (node) {
|
|
631
|
+
node.$isAlive = false;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
nodeRegistry.clear();
|
|
635
|
+
identifierRegistry.clear();
|
|
636
|
+
nodeIdCounter = 0;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// ============================================================================
|
|
640
|
+
// Tree Navigation Functions
|
|
641
|
+
// ============================================================================
|
|
642
|
+
|
|
643
|
+
/** Get the root of the tree */
|
|
644
|
+
export function getRoot<T>(target: T): T {
|
|
645
|
+
const node = getStateTreeNode(target);
|
|
646
|
+
const rootNode = node.getRoot();
|
|
647
|
+
return rootNode.getInstance() as T;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/** Get the parent of a node */
|
|
651
|
+
export function getParent<T = unknown>(target: unknown, depth: number = 1): T {
|
|
652
|
+
let node = getStateTreeNode(target);
|
|
653
|
+
for (let i = 0; i < depth; i++) {
|
|
654
|
+
if (!node.$parent) {
|
|
655
|
+
throw new Error("[jotai-state-tree] Cannot get parent of root node");
|
|
656
|
+
}
|
|
657
|
+
node = node.$parent;
|
|
658
|
+
}
|
|
659
|
+
return node.getInstance() as T;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/** Try to get the parent, returns undefined if at root */
|
|
663
|
+
export function tryGetParent<T = unknown>(
|
|
664
|
+
target: unknown,
|
|
665
|
+
depth: number = 1,
|
|
666
|
+
): T | undefined {
|
|
667
|
+
try {
|
|
668
|
+
return getParent<T>(target, depth);
|
|
669
|
+
} catch {
|
|
670
|
+
return undefined;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** Check if a node has a parent */
|
|
675
|
+
export function hasParent(target: unknown, depth: number = 1): boolean {
|
|
676
|
+
let node = getStateTreeNode(target);
|
|
677
|
+
for (let i = 0; i < depth; i++) {
|
|
678
|
+
if (!node.$parent) return false;
|
|
679
|
+
node = node.$parent;
|
|
680
|
+
}
|
|
681
|
+
return true;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** Get parent of specific type */
|
|
685
|
+
export function getParentOfType<T extends IAnyModelType>(
|
|
686
|
+
target: unknown,
|
|
687
|
+
type: T,
|
|
688
|
+
): T extends IType<unknown, unknown, infer I> ? I : never {
|
|
689
|
+
let node: StateTreeNode | null = getStateTreeNode(target).$parent;
|
|
690
|
+
|
|
691
|
+
while (node) {
|
|
692
|
+
if (node.$type === type || node.$type.name === type.name) {
|
|
693
|
+
return node.getInstance() as T extends IType<unknown, unknown, infer I>
|
|
694
|
+
? I
|
|
695
|
+
: never;
|
|
696
|
+
}
|
|
697
|
+
node = node.$parent;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
throw new Error(`[jotai-state-tree] No parent of type '${type.name}' found`);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/** Get the path of a node */
|
|
704
|
+
export function getPath(target: unknown): string {
|
|
705
|
+
return getStateTreeNode(target).$path;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/** Get path parts as array */
|
|
709
|
+
export function getPathParts(target: unknown): string[] {
|
|
710
|
+
const path = getPath(target);
|
|
711
|
+
return path ? path.split("/").filter(Boolean) : [];
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/** Get the environment */
|
|
715
|
+
export function getEnv<E = unknown>(target: unknown): E {
|
|
716
|
+
return getStateTreeNode(target).$env as E;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/** Check if node is alive */
|
|
720
|
+
export function isAlive(target: unknown): boolean {
|
|
721
|
+
try {
|
|
722
|
+
return getStateTreeNode(target).$isAlive;
|
|
723
|
+
} catch {
|
|
724
|
+
return false;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/** Check if node is root */
|
|
729
|
+
export function isRoot(target: unknown): boolean {
|
|
730
|
+
return getStateTreeNode(target).$parent === null;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/** Get the type of a node */
|
|
734
|
+
export function getType(target: unknown): IAnyType {
|
|
735
|
+
return getStateTreeNode(target).$type;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/** Check if value is a state tree node */
|
|
739
|
+
export function isStateTreeNode(value: unknown): boolean {
|
|
740
|
+
return hasStateTreeNode(value);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/** Get identifier of a node */
|
|
744
|
+
export function getIdentifier(target: unknown): string | number | null {
|
|
745
|
+
const node = getStateTreeNode(target);
|
|
746
|
+
return node.identifierValue ?? null;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/** Destroy a node */
|
|
750
|
+
export function destroy(target: unknown): void {
|
|
751
|
+
const node = getStateTreeNode(target);
|
|
752
|
+
node.destroy();
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/** Detach a node from its parent */
|
|
756
|
+
export function detach<T>(target: T): T {
|
|
757
|
+
const node = getStateTreeNode(target);
|
|
758
|
+
node.detach();
|
|
759
|
+
return target;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/** Clone a node */
|
|
763
|
+
export function clone<T>(target: T, keepEnvironment: boolean = true): T {
|
|
764
|
+
const node = getStateTreeNode(target);
|
|
765
|
+
const snapshot = getSnapshotFromNode(node);
|
|
766
|
+
const type = node.$type;
|
|
767
|
+
return type.create(snapshot, keepEnvironment ? node.$env : undefined) as T;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// ============================================================================
|
|
771
|
+
// Snapshot & Patch Functions
|
|
772
|
+
// ============================================================================
|
|
773
|
+
|
|
774
|
+
/** Get snapshot from an instance */
|
|
775
|
+
export function getSnapshot<S>(target: unknown): S {
|
|
776
|
+
const node = getStateTreeNode(target);
|
|
777
|
+
return getSnapshotFromNode(node) as S;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/** Apply snapshot to an instance */
|
|
781
|
+
export function applySnapshot<S>(target: unknown, snapshot: S): void {
|
|
782
|
+
const node = getStateTreeNode(target);
|
|
783
|
+
applySnapshotToNode(node, snapshot);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/** Subscribe to snapshots */
|
|
787
|
+
export function onSnapshot<S>(
|
|
788
|
+
target: unknown,
|
|
789
|
+
listener: (snapshot: S) => void,
|
|
790
|
+
): IDisposer {
|
|
791
|
+
const node = getStateTreeNode(target);
|
|
792
|
+
return node.onSnapshot(listener as (snapshot: unknown) => void);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/** Subscribe to patches */
|
|
796
|
+
export function onPatch(
|
|
797
|
+
target: unknown,
|
|
798
|
+
listener: (patch: IJsonPatch, reversePatch: IReversibleJsonPatch) => void,
|
|
799
|
+
): IDisposer {
|
|
800
|
+
const node = getStateTreeNode(target);
|
|
801
|
+
return node.onPatch(listener);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/** Apply a single patch */
|
|
805
|
+
export function applyPatch(
|
|
806
|
+
target: unknown,
|
|
807
|
+
patch: IJsonPatch | IJsonPatch[],
|
|
808
|
+
): void {
|
|
809
|
+
const patches = Array.isArray(patch) ? patch : [patch];
|
|
810
|
+
const rootNode = getStateTreeNode(target).getRoot();
|
|
811
|
+
|
|
812
|
+
for (const p of patches) {
|
|
813
|
+
applyPatchToNode(rootNode, p);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function applyPatchToNode(rootNode: StateTreeNode, patch: IJsonPatch): void {
|
|
818
|
+
const pathParts = patch.path.split("/").filter(Boolean);
|
|
819
|
+
let node = rootNode;
|
|
820
|
+
|
|
821
|
+
// Navigate to the target node
|
|
822
|
+
for (let i = 0; i < pathParts.length - 1; i++) {
|
|
823
|
+
const childNode = node.getChild(pathParts[i]);
|
|
824
|
+
if (!childNode) {
|
|
825
|
+
throw new Error(`[jotai-state-tree] Invalid patch path: ${patch.path}`);
|
|
826
|
+
}
|
|
827
|
+
node = childNode;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
const key = pathParts[pathParts.length - 1];
|
|
831
|
+
|
|
832
|
+
switch (patch.op) {
|
|
833
|
+
case "replace": {
|
|
834
|
+
const childNode = node.getChild(key);
|
|
835
|
+
if (childNode) {
|
|
836
|
+
applySnapshotToNode(childNode, patch.value);
|
|
837
|
+
} else {
|
|
838
|
+
// Direct value set for primitives
|
|
839
|
+
const currentValue = node.getValue() as Record<string, unknown>;
|
|
840
|
+
currentValue[key] = patch.value;
|
|
841
|
+
node.setValue(currentValue);
|
|
842
|
+
}
|
|
843
|
+
break;
|
|
844
|
+
}
|
|
845
|
+
case "add": {
|
|
846
|
+
const currentValue = node.getValue();
|
|
847
|
+
if (Array.isArray(currentValue)) {
|
|
848
|
+
const index = key === "-" ? currentValue.length : parseInt(key, 10);
|
|
849
|
+
currentValue.splice(index, 0, patch.value);
|
|
850
|
+
node.setValue([...currentValue]);
|
|
851
|
+
} else if (typeof currentValue === "object" && currentValue !== null) {
|
|
852
|
+
(currentValue as Record<string, unknown>)[key] = patch.value;
|
|
853
|
+
node.setValue({ ...currentValue });
|
|
854
|
+
}
|
|
855
|
+
break;
|
|
856
|
+
}
|
|
857
|
+
case "remove": {
|
|
858
|
+
const currentValue = node.getValue();
|
|
859
|
+
if (Array.isArray(currentValue)) {
|
|
860
|
+
const index = parseInt(key, 10);
|
|
861
|
+
currentValue.splice(index, 1);
|
|
862
|
+
node.setValue([...currentValue]);
|
|
863
|
+
} else if (typeof currentValue === "object" && currentValue !== null) {
|
|
864
|
+
delete (currentValue as Record<string, unknown>)[key];
|
|
865
|
+
node.setValue({ ...currentValue });
|
|
866
|
+
}
|
|
867
|
+
break;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/** Record patches during a function execution */
|
|
873
|
+
export function recordPatches(target: unknown): {
|
|
874
|
+
patches: IJsonPatch[];
|
|
875
|
+
inversePatches: IReversibleJsonPatch[];
|
|
876
|
+
stop: () => void;
|
|
877
|
+
resume: () => void;
|
|
878
|
+
replay: (target: unknown) => void;
|
|
879
|
+
undo: (target: unknown) => void;
|
|
880
|
+
} {
|
|
881
|
+
const patches: IJsonPatch[] = [];
|
|
882
|
+
const inversePatches: IReversibleJsonPatch[] = [];
|
|
883
|
+
let recording = true;
|
|
884
|
+
|
|
885
|
+
const disposer = onPatch(target, (patch, reversePatch) => {
|
|
886
|
+
if (recording) {
|
|
887
|
+
patches.push(patch);
|
|
888
|
+
inversePatches.push(reversePatch);
|
|
889
|
+
}
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
return {
|
|
893
|
+
patches,
|
|
894
|
+
inversePatches,
|
|
895
|
+
stop: () => {
|
|
896
|
+
recording = false;
|
|
897
|
+
disposer();
|
|
898
|
+
},
|
|
899
|
+
resume: () => {
|
|
900
|
+
recording = true;
|
|
901
|
+
},
|
|
902
|
+
replay: (t: unknown) => {
|
|
903
|
+
applyPatch(t, patches);
|
|
904
|
+
},
|
|
905
|
+
undo: (t: unknown) => {
|
|
906
|
+
applyPatch(t, inversePatches.slice().reverse());
|
|
907
|
+
},
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// ============================================================================
|
|
912
|
+
// Action Tracking
|
|
913
|
+
// ============================================================================
|
|
914
|
+
|
|
915
|
+
interface ActionContext {
|
|
916
|
+
name: string;
|
|
917
|
+
args: unknown[];
|
|
918
|
+
tree: StateTreeNode;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
let currentAction: ActionContext | null = null;
|
|
922
|
+
const actionListeners = new Set<(call: ActionCall) => void>();
|
|
923
|
+
|
|
924
|
+
/** Action recorder hooks - set by lifecycle.ts to avoid circular imports */
|
|
925
|
+
const actionRecorderHooks: Array<
|
|
926
|
+
(node: StateTreeNode, call: ActionCall) => void
|
|
927
|
+
> = [];
|
|
928
|
+
|
|
929
|
+
/** Register an action recorder hook (called by lifecycle.ts) */
|
|
930
|
+
export function registerActionRecorderHook(
|
|
931
|
+
hook: (node: StateTreeNode, call: ActionCall) => void,
|
|
932
|
+
): () => void {
|
|
933
|
+
actionRecorderHooks.push(hook);
|
|
934
|
+
return () => {
|
|
935
|
+
const index = actionRecorderHooks.indexOf(hook);
|
|
936
|
+
if (index >= 0) {
|
|
937
|
+
actionRecorderHooks.splice(index, 1);
|
|
938
|
+
}
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
export interface ActionCall {
|
|
943
|
+
name: string;
|
|
944
|
+
path: string;
|
|
945
|
+
args: unknown[];
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/** Track an action call */
|
|
949
|
+
export function trackAction<T>(
|
|
950
|
+
node: StateTreeNode,
|
|
951
|
+
name: string,
|
|
952
|
+
args: unknown[],
|
|
953
|
+
fn: () => T,
|
|
954
|
+
): T {
|
|
955
|
+
const previousAction = currentAction;
|
|
956
|
+
currentAction = { name, args, tree: node };
|
|
957
|
+
|
|
958
|
+
try {
|
|
959
|
+
const result = fn();
|
|
960
|
+
|
|
961
|
+
// Notify action listeners
|
|
962
|
+
const call: ActionCall = {
|
|
963
|
+
name,
|
|
964
|
+
path: node.$path,
|
|
965
|
+
args,
|
|
966
|
+
};
|
|
967
|
+
actionListeners.forEach((listener) => listener(call));
|
|
968
|
+
|
|
969
|
+
// Notify action recorder hooks (registered by lifecycle.ts)
|
|
970
|
+
actionRecorderHooks.forEach((hook) => hook(node, call));
|
|
971
|
+
|
|
972
|
+
return result;
|
|
973
|
+
} finally {
|
|
974
|
+
currentAction = previousAction;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/** Subscribe to action calls */
|
|
979
|
+
export function onAction(
|
|
980
|
+
target: unknown,
|
|
981
|
+
listener: (call: ActionCall) => void,
|
|
982
|
+
): IDisposer {
|
|
983
|
+
actionListeners.add(listener);
|
|
984
|
+
return () => {
|
|
985
|
+
actionListeners.delete(listener);
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
// ============================================================================
|
|
990
|
+
// Utilities
|
|
991
|
+
// ============================================================================
|
|
992
|
+
|
|
993
|
+
/** Walk the tree */
|
|
994
|
+
export function walk(target: unknown, visitor: (node: unknown) => void): void {
|
|
995
|
+
const treeNode = getStateTreeNode(target);
|
|
996
|
+
|
|
997
|
+
function visitNode(node: StateTreeNode) {
|
|
998
|
+
const instance = node.getInstance();
|
|
999
|
+
if (instance) {
|
|
1000
|
+
visitor(instance);
|
|
1001
|
+
}
|
|
1002
|
+
node.getChildren().forEach(visitNode);
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
visitNode(treeNode);
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
/** Get all members (properties) of a node */
|
|
1009
|
+
export function getMembers(target: unknown): {
|
|
1010
|
+
name: string;
|
|
1011
|
+
type: "view" | "action" | "property" | "volatile";
|
|
1012
|
+
value: unknown;
|
|
1013
|
+
}[] {
|
|
1014
|
+
const result: {
|
|
1015
|
+
name: string;
|
|
1016
|
+
type: "view" | "action" | "property" | "volatile";
|
|
1017
|
+
value: unknown;
|
|
1018
|
+
}[] = [];
|
|
1019
|
+
const node = getStateTreeNode(target);
|
|
1020
|
+
const instance = target as Record<string, unknown>;
|
|
1021
|
+
|
|
1022
|
+
// Get properties from children
|
|
1023
|
+
for (const [key] of node.getChildren()) {
|
|
1024
|
+
result.push({
|
|
1025
|
+
name: key,
|
|
1026
|
+
type: "property",
|
|
1027
|
+
value: instance[key],
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// Get volatile state
|
|
1032
|
+
for (const [key, value] of Object.entries(node.volatileState)) {
|
|
1033
|
+
result.push({
|
|
1034
|
+
name: key,
|
|
1035
|
+
type: "volatile",
|
|
1036
|
+
value,
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
return result;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/** Resolve a path to a node */
|
|
1044
|
+
export function resolvePath(target: unknown, path: string): unknown {
|
|
1045
|
+
const parts = path.split("/").filter(Boolean);
|
|
1046
|
+
let node = getStateTreeNode(target);
|
|
1047
|
+
|
|
1048
|
+
for (const part of parts) {
|
|
1049
|
+
const child = node.getChild(part);
|
|
1050
|
+
if (!child) {
|
|
1051
|
+
throw new Error(`[jotai-state-tree] Invalid path: ${path}`);
|
|
1052
|
+
}
|
|
1053
|
+
node = child;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
return node.getInstance();
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
/** Try to resolve a path */
|
|
1060
|
+
export function tryResolve(target: unknown, path: string): unknown | undefined {
|
|
1061
|
+
try {
|
|
1062
|
+
return resolvePath(target, path);
|
|
1063
|
+
} catch {
|
|
1064
|
+
return undefined;
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/** Get the relative path from one node to another */
|
|
1069
|
+
export function getRelativePath(from: unknown, to: unknown): string {
|
|
1070
|
+
const fromNode = getStateTreeNode(from);
|
|
1071
|
+
const toNode = getStateTreeNode(to);
|
|
1072
|
+
|
|
1073
|
+
const fromParts = fromNode.$path.split("/").filter(Boolean);
|
|
1074
|
+
const toParts = toNode.$path.split("/").filter(Boolean);
|
|
1075
|
+
|
|
1076
|
+
// Find common ancestor
|
|
1077
|
+
let commonLength = 0;
|
|
1078
|
+
for (let i = 0; i < Math.min(fromParts.length, toParts.length); i++) {
|
|
1079
|
+
if (fromParts[i] === toParts[i]) {
|
|
1080
|
+
commonLength++;
|
|
1081
|
+
} else {
|
|
1082
|
+
break;
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// Build relative path
|
|
1087
|
+
const upCount = fromParts.length - commonLength;
|
|
1088
|
+
const downParts = toParts.slice(commonLength);
|
|
1089
|
+
|
|
1090
|
+
const parts: string[] = [];
|
|
1091
|
+
for (let i = 0; i < upCount; i++) {
|
|
1092
|
+
parts.push("..");
|
|
1093
|
+
}
|
|
1094
|
+
parts.push(...downParts);
|
|
1095
|
+
|
|
1096
|
+
return parts.join("/") || ".";
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
/** Check if a node is an ancestor of another */
|
|
1100
|
+
export function isAncestor(ancestor: unknown, descendant: unknown): boolean {
|
|
1101
|
+
const ancestorNode = getStateTreeNode(ancestor);
|
|
1102
|
+
let currentNode: StateTreeNode | null = getStateTreeNode(descendant);
|
|
1103
|
+
|
|
1104
|
+
while (currentNode) {
|
|
1105
|
+
if (currentNode === ancestorNode) {
|
|
1106
|
+
return true;
|
|
1107
|
+
}
|
|
1108
|
+
currentNode = currentNode.$parent;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
return false;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
/** Check if two nodes share a common root */
|
|
1115
|
+
export function haveSameRoot(a: unknown, b: unknown): boolean {
|
|
1116
|
+
return getRoot(a) === getRoot(b);
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
/** Get all nodes of a specific type in the tree */
|
|
1120
|
+
export function findAll<T>(
|
|
1121
|
+
target: unknown,
|
|
1122
|
+
predicate: (node: unknown) => node is T,
|
|
1123
|
+
): T[] {
|
|
1124
|
+
const results: T[] = [];
|
|
1125
|
+
|
|
1126
|
+
walk(target, (node) => {
|
|
1127
|
+
if (predicate(node)) {
|
|
1128
|
+
results.push(node);
|
|
1129
|
+
}
|
|
1130
|
+
});
|
|
1131
|
+
|
|
1132
|
+
return results;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
/** Get the first node matching a predicate */
|
|
1136
|
+
export function findFirst<T>(
|
|
1137
|
+
target: unknown,
|
|
1138
|
+
predicate: (node: unknown) => node is T,
|
|
1139
|
+
): T | undefined {
|
|
1140
|
+
let result: T | undefined;
|
|
1141
|
+
|
|
1142
|
+
walk(target, (node) => {
|
|
1143
|
+
if (!result && predicate(node)) {
|
|
1144
|
+
result = node;
|
|
1145
|
+
}
|
|
1146
|
+
});
|
|
1147
|
+
|
|
1148
|
+
return result;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/** Check if a value is a valid reference target */
|
|
1152
|
+
export function isValidReference(
|
|
1153
|
+
target: unknown,
|
|
1154
|
+
identifier: string | number,
|
|
1155
|
+
): boolean {
|
|
1156
|
+
if (!hasStateTreeNode(target)) return false;
|
|
1157
|
+
|
|
1158
|
+
const node = getStateTreeNode(target);
|
|
1159
|
+
const typeName = node.$type.name;
|
|
1160
|
+
|
|
1161
|
+
try {
|
|
1162
|
+
const resolved = resolveIdentifier(typeName, identifier);
|
|
1163
|
+
return resolved !== undefined;
|
|
1164
|
+
} catch {
|
|
1165
|
+
return false;
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
/** Get statistics about the tree */
|
|
1170
|
+
export function getTreeStats(target: unknown): {
|
|
1171
|
+
nodeCount: number;
|
|
1172
|
+
depth: number;
|
|
1173
|
+
types: Record<string, number>;
|
|
1174
|
+
} {
|
|
1175
|
+
let nodeCount = 0;
|
|
1176
|
+
let maxDepth = 0;
|
|
1177
|
+
const types: Record<string, number> = {};
|
|
1178
|
+
|
|
1179
|
+
walk(target, (node) => {
|
|
1180
|
+
if (!hasStateTreeNode(node)) return;
|
|
1181
|
+
|
|
1182
|
+
const stateNode = getStateTreeNode(node);
|
|
1183
|
+
nodeCount++;
|
|
1184
|
+
|
|
1185
|
+
const depth = stateNode.$path.split("/").filter(Boolean).length;
|
|
1186
|
+
maxDepth = Math.max(maxDepth, depth);
|
|
1187
|
+
|
|
1188
|
+
const typeName = stateNode.$type.name;
|
|
1189
|
+
types[typeName] = (types[typeName] || 0) + 1;
|
|
1190
|
+
});
|
|
1191
|
+
|
|
1192
|
+
return {
|
|
1193
|
+
nodeCount,
|
|
1194
|
+
depth: maxDepth,
|
|
1195
|
+
types,
|
|
1196
|
+
};
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
/** Create a deep observable copy of a tree */
|
|
1200
|
+
export function cloneDeep<T>(target: T): T {
|
|
1201
|
+
const snapshot = getSnapshot(target);
|
|
1202
|
+
const node = getStateTreeNode(target);
|
|
1203
|
+
return node.$type.create(snapshot, node.$env) as T;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
/** Get or create a node by path */
|
|
1207
|
+
export function getOrCreatePath(
|
|
1208
|
+
target: unknown,
|
|
1209
|
+
path: string,
|
|
1210
|
+
creator: () => unknown,
|
|
1211
|
+
): unknown {
|
|
1212
|
+
const parts = path.split("/").filter(Boolean);
|
|
1213
|
+
let node = getStateTreeNode(target);
|
|
1214
|
+
|
|
1215
|
+
for (let i = 0; i < parts.length; i++) {
|
|
1216
|
+
const part = parts[i];
|
|
1217
|
+
let child = node.getChild(part);
|
|
1218
|
+
|
|
1219
|
+
if (!child && i === parts.length - 1) {
|
|
1220
|
+
// Last part - create if needed
|
|
1221
|
+
const instance = creator();
|
|
1222
|
+
if (hasStateTreeNode(instance)) {
|
|
1223
|
+
child = getStateTreeNode(instance);
|
|
1224
|
+
node.addChild(part, child);
|
|
1225
|
+
} else {
|
|
1226
|
+
throw new Error(
|
|
1227
|
+
"[jotai-state-tree] Creator must return a state tree node",
|
|
1228
|
+
);
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
if (!child) {
|
|
1233
|
+
throw new Error(`[jotai-state-tree] Invalid path: ${path}`);
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
node = child;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
return node.getInstance();
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
/** Freeze a node, making it read-only */
|
|
1243
|
+
export function freeze(target: unknown): void {
|
|
1244
|
+
const node = getStateTreeNode(target);
|
|
1245
|
+
// Mark node as frozen by setting a flag in volatile state
|
|
1246
|
+
node.volatileState.$frozen = true;
|
|
1247
|
+
|
|
1248
|
+
// Freeze all children
|
|
1249
|
+
for (const [, child] of node.getChildren()) {
|
|
1250
|
+
const instance = child.getInstance();
|
|
1251
|
+
if (instance && hasStateTreeNode(instance)) {
|
|
1252
|
+
freeze(instance);
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
/** Check if a node is frozen */
|
|
1258
|
+
export function isFrozen(target: unknown): boolean {
|
|
1259
|
+
const node = getStateTreeNode(target);
|
|
1260
|
+
return node.volatileState.$frozen === true;
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
/** Unfreeze a node */
|
|
1264
|
+
export function unfreeze(target: unknown): void {
|
|
1265
|
+
const node = getStateTreeNode(target);
|
|
1266
|
+
delete node.volatileState.$frozen;
|
|
1267
|
+
|
|
1268
|
+
// Unfreeze all children
|
|
1269
|
+
for (const [, child] of node.getChildren()) {
|
|
1270
|
+
const instance = child.getInstance();
|
|
1271
|
+
if (instance && hasStateTreeNode(instance)) {
|
|
1272
|
+
unfreeze(instance);
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
}
|