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/lifecycle.ts
ADDED
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lifecycle hooks and middleware system
|
|
3
|
+
* Implements afterCreate, beforeDestroy, afterAttach, beforeDetach, etc.
|
|
4
|
+
*
|
|
5
|
+
* MEMORY MANAGEMENT:
|
|
6
|
+
* - Uses WeakMap for all node-keyed registries to allow GC
|
|
7
|
+
* - Action recorders are stored in WeakMap to prevent memory leaks
|
|
8
|
+
* - All registries automatically clean up when nodes are garbage collected
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { IDisposer } from "./types";
|
|
12
|
+
import {
|
|
13
|
+
StateTreeNode,
|
|
14
|
+
getStateTreeNode,
|
|
15
|
+
registerActionRecorderHook,
|
|
16
|
+
type ActionCall,
|
|
17
|
+
} from "./tree";
|
|
18
|
+
|
|
19
|
+
// ============================================================================
|
|
20
|
+
// Lifecycle Hook Types
|
|
21
|
+
// ============================================================================
|
|
22
|
+
|
|
23
|
+
export interface ILifecycleHooks {
|
|
24
|
+
afterCreate?(): void;
|
|
25
|
+
afterAttach?(): void;
|
|
26
|
+
beforeDetach?(): void;
|
|
27
|
+
beforeDestroy?(): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface IHooksConfig {
|
|
31
|
+
afterCreate?: () => void;
|
|
32
|
+
afterAttach?: () => void;
|
|
33
|
+
beforeDetach?: () => void;
|
|
34
|
+
beforeDestroy?: () => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ============================================================================
|
|
38
|
+
// Hook Registration (WeakMap - allows GC)
|
|
39
|
+
// ============================================================================
|
|
40
|
+
|
|
41
|
+
const nodeHooks = new WeakMap<StateTreeNode, IHooksConfig>();
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Register lifecycle hooks for a node
|
|
45
|
+
*/
|
|
46
|
+
export function registerHooks(node: StateTreeNode, hooks: IHooksConfig): void {
|
|
47
|
+
const existing = nodeHooks.get(node) || {};
|
|
48
|
+
nodeHooks.set(node, { ...existing, ...hooks });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Get hooks for a node
|
|
53
|
+
*/
|
|
54
|
+
export function getHooks(node: StateTreeNode): IHooksConfig | undefined {
|
|
55
|
+
return nodeHooks.get(node);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Run afterCreate hook
|
|
60
|
+
*/
|
|
61
|
+
export function runAfterCreate(node: StateTreeNode): void {
|
|
62
|
+
const hooks = nodeHooks.get(node);
|
|
63
|
+
if (hooks?.afterCreate) {
|
|
64
|
+
hooks.afterCreate();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Run afterAttach hook
|
|
70
|
+
*/
|
|
71
|
+
export function runAfterAttach(node: StateTreeNode): void {
|
|
72
|
+
const hooks = nodeHooks.get(node);
|
|
73
|
+
if (hooks?.afterAttach) {
|
|
74
|
+
hooks.afterAttach();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Run beforeDetach hook
|
|
80
|
+
*/
|
|
81
|
+
export function runBeforeDetach(node: StateTreeNode): void {
|
|
82
|
+
const hooks = nodeHooks.get(node);
|
|
83
|
+
if (hooks?.beforeDetach) {
|
|
84
|
+
hooks.beforeDetach();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Run beforeDestroy hook
|
|
90
|
+
*/
|
|
91
|
+
export function runBeforeDestroy(node: StateTreeNode): void {
|
|
92
|
+
const hooks = nodeHooks.get(node);
|
|
93
|
+
if (hooks?.beforeDestroy) {
|
|
94
|
+
hooks.beforeDestroy();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ============================================================================
|
|
99
|
+
// Middleware System
|
|
100
|
+
// ============================================================================
|
|
101
|
+
|
|
102
|
+
export interface IMiddlewareEvent {
|
|
103
|
+
type:
|
|
104
|
+
| "action"
|
|
105
|
+
| "flow_spawn"
|
|
106
|
+
| "flow_resume"
|
|
107
|
+
| "flow_resume_error"
|
|
108
|
+
| "flow_return"
|
|
109
|
+
| "flow_throw";
|
|
110
|
+
name: string;
|
|
111
|
+
id: number;
|
|
112
|
+
parentId: number;
|
|
113
|
+
rootId: number;
|
|
114
|
+
context: unknown;
|
|
115
|
+
tree: unknown;
|
|
116
|
+
args: unknown[];
|
|
117
|
+
parentEvent?: IMiddlewareEvent;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface IMiddlewareHandler {
|
|
121
|
+
(
|
|
122
|
+
call: IMiddlewareEvent,
|
|
123
|
+
next: (
|
|
124
|
+
call: IMiddlewareEvent,
|
|
125
|
+
callback?: (value: unknown) => unknown,
|
|
126
|
+
) => unknown,
|
|
127
|
+
abort: (value: unknown) => unknown,
|
|
128
|
+
): unknown;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface IActionContext {
|
|
132
|
+
name: string;
|
|
133
|
+
context: unknown;
|
|
134
|
+
tree: unknown;
|
|
135
|
+
args: unknown[];
|
|
136
|
+
parentActionEvent?: IMiddlewareEvent;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Global middleware stack - these are global handlers, not per-node
|
|
140
|
+
const middlewareStack: IMiddlewareHandler[] = [];
|
|
141
|
+
let middlewareIdCounter = 0;
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Add middleware to the global stack
|
|
145
|
+
*/
|
|
146
|
+
export function addMiddleware(
|
|
147
|
+
target: unknown,
|
|
148
|
+
handler: IMiddlewareHandler,
|
|
149
|
+
includeHooks: boolean = true,
|
|
150
|
+
): IDisposer {
|
|
151
|
+
middlewareStack.push(handler);
|
|
152
|
+
return () => {
|
|
153
|
+
const index = middlewareStack.indexOf(handler);
|
|
154
|
+
if (index >= 0) {
|
|
155
|
+
middlewareStack.splice(index, 1);
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Create a middleware runner
|
|
162
|
+
*/
|
|
163
|
+
export function createMiddlewareRunner(
|
|
164
|
+
node: StateTreeNode,
|
|
165
|
+
actionName: string,
|
|
166
|
+
args: unknown[],
|
|
167
|
+
): (fn: () => unknown) => unknown {
|
|
168
|
+
if (middlewareStack.length === 0) {
|
|
169
|
+
return (fn) => fn();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const id = ++middlewareIdCounter;
|
|
173
|
+
const event: IMiddlewareEvent = {
|
|
174
|
+
type: "action",
|
|
175
|
+
name: actionName,
|
|
176
|
+
id,
|
|
177
|
+
parentId: 0,
|
|
178
|
+
rootId: id,
|
|
179
|
+
context: node.getInstance(),
|
|
180
|
+
tree: node.getRoot().getInstance(),
|
|
181
|
+
args,
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
return (fn: () => unknown) => {
|
|
185
|
+
let index = 0;
|
|
186
|
+
let aborted = false;
|
|
187
|
+
let abortValue: unknown;
|
|
188
|
+
|
|
189
|
+
const abort = (value: unknown) => {
|
|
190
|
+
aborted = true;
|
|
191
|
+
abortValue = value;
|
|
192
|
+
return value;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const next = (
|
|
196
|
+
call: IMiddlewareEvent,
|
|
197
|
+
callback?: (value: unknown) => unknown,
|
|
198
|
+
): unknown => {
|
|
199
|
+
if (aborted) return abortValue;
|
|
200
|
+
|
|
201
|
+
if (index >= middlewareStack.length) {
|
|
202
|
+
const result = fn();
|
|
203
|
+
return callback ? callback(result) : result;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const middleware = middlewareStack[index++];
|
|
207
|
+
return middleware(call, next, abort);
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
return next(event);
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ============================================================================
|
|
215
|
+
// Action Tracking Context
|
|
216
|
+
// ============================================================================
|
|
217
|
+
|
|
218
|
+
interface ActionCallContext {
|
|
219
|
+
name: string;
|
|
220
|
+
args: unknown[];
|
|
221
|
+
tree: StateTreeNode;
|
|
222
|
+
parentContext?: ActionCallContext;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
let currentActionContext: ActionCallContext | null = null;
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Get the current action context
|
|
229
|
+
*/
|
|
230
|
+
export function getRunningActionContext(): ActionCallContext | null {
|
|
231
|
+
return currentActionContext;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Set the current action context
|
|
236
|
+
*/
|
|
237
|
+
export function setRunningActionContext(
|
|
238
|
+
context: ActionCallContext | null,
|
|
239
|
+
): void {
|
|
240
|
+
currentActionContext = context;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ============================================================================
|
|
244
|
+
// Action Recording (WeakMap - allows GC of nodes)
|
|
245
|
+
// ============================================================================
|
|
246
|
+
|
|
247
|
+
export interface ISerializedActionCall {
|
|
248
|
+
name: string;
|
|
249
|
+
path: string;
|
|
250
|
+
args: unknown[];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* WeakMap for action recorders - allows nodes to be garbage collected
|
|
255
|
+
* even if they have recorders attached
|
|
256
|
+
*/
|
|
257
|
+
const actionRecorders = new WeakMap<
|
|
258
|
+
StateTreeNode,
|
|
259
|
+
Set<(action: ISerializedActionCall) => void>
|
|
260
|
+
>();
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Record all actions on a subtree
|
|
264
|
+
*
|
|
265
|
+
* MEMORY SAFETY: Uses WeakMap so nodes can be garbage collected
|
|
266
|
+
* even while recording is active. The stop() function properly
|
|
267
|
+
* cleans up the recorder from the Set.
|
|
268
|
+
*/
|
|
269
|
+
export function recordActions(target: unknown): {
|
|
270
|
+
actions: ISerializedActionCall[];
|
|
271
|
+
stop: () => void;
|
|
272
|
+
replay: (target: unknown) => void;
|
|
273
|
+
} {
|
|
274
|
+
const node = getStateTreeNode(target);
|
|
275
|
+
const actions: ISerializedActionCall[] = [];
|
|
276
|
+
|
|
277
|
+
const recorder = (action: ISerializedActionCall) => {
|
|
278
|
+
// Only record if node is still alive
|
|
279
|
+
if (node.$isAlive) {
|
|
280
|
+
actions.push(action);
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
let recorders = actionRecorders.get(node);
|
|
285
|
+
if (!recorders) {
|
|
286
|
+
recorders = new Set();
|
|
287
|
+
actionRecorders.set(node, recorders);
|
|
288
|
+
}
|
|
289
|
+
recorders.add(recorder);
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
actions,
|
|
293
|
+
stop: () => {
|
|
294
|
+
const currentRecorders = actionRecorders.get(node);
|
|
295
|
+
if (currentRecorders) {
|
|
296
|
+
currentRecorders.delete(recorder);
|
|
297
|
+
// Clean up empty Sets to avoid memory waste
|
|
298
|
+
if (currentRecorders.size === 0) {
|
|
299
|
+
// WeakMap doesn't have delete, but setting to undefined
|
|
300
|
+
// or just leaving empty Set is fine - WeakMap handles GC
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
},
|
|
304
|
+
replay: (replayTarget: unknown) => {
|
|
305
|
+
const replayNode = getStateTreeNode(replayTarget);
|
|
306
|
+
for (const action of actions) {
|
|
307
|
+
const instance = replayNode.getInstance() as Record<string, Function>;
|
|
308
|
+
if (typeof instance[action.name] === "function") {
|
|
309
|
+
instance[action.name](...action.args);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Notify action recorders
|
|
318
|
+
*/
|
|
319
|
+
export function notifyActionRecorders(
|
|
320
|
+
node: StateTreeNode,
|
|
321
|
+
action: ISerializedActionCall,
|
|
322
|
+
): void {
|
|
323
|
+
// Walk up the tree and notify all recorders
|
|
324
|
+
let current: StateTreeNode | null = node;
|
|
325
|
+
while (current) {
|
|
326
|
+
const recorders = actionRecorders.get(current);
|
|
327
|
+
if (recorders) {
|
|
328
|
+
recorders.forEach((recorder) => recorder(action));
|
|
329
|
+
}
|
|
330
|
+
current = current.$parent;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Register the action recorder hook with tree.ts
|
|
335
|
+
// This is called at module load time to connect action tracking with recording
|
|
336
|
+
registerActionRecorderHook((node: StateTreeNode, call: ActionCall) => {
|
|
337
|
+
const action: ISerializedActionCall = {
|
|
338
|
+
name: call.name,
|
|
339
|
+
path: call.path,
|
|
340
|
+
args: call.args,
|
|
341
|
+
};
|
|
342
|
+
notifyActionRecorders(node, action);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// ============================================================================
|
|
346
|
+
// Protect / Unprotect (WeakSet - allows GC)
|
|
347
|
+
// ============================================================================
|
|
348
|
+
|
|
349
|
+
const protectedNodes = new WeakSet<StateTreeNode>();
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Protect a node from direct mutations outside of actions
|
|
353
|
+
*/
|
|
354
|
+
export function protect(target: unknown): void {
|
|
355
|
+
const node = getStateTreeNode(target);
|
|
356
|
+
protectedNodes.add(node);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Unprotect a node to allow direct mutations
|
|
361
|
+
*/
|
|
362
|
+
export function unprotect(target: unknown): void {
|
|
363
|
+
const node = getStateTreeNode(target);
|
|
364
|
+
protectedNodes.delete(node);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Check if a node is protected
|
|
369
|
+
*/
|
|
370
|
+
export function isProtected(target: unknown): boolean {
|
|
371
|
+
const node = getStateTreeNode(target);
|
|
372
|
+
return protectedNodes.has(node);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Check if we can write to a node
|
|
377
|
+
*/
|
|
378
|
+
export function canWrite(node: StateTreeNode): boolean {
|
|
379
|
+
// If not protected, can always write
|
|
380
|
+
if (!protectedNodes.has(node)) {
|
|
381
|
+
return true;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// If protected, must be inside an action
|
|
385
|
+
return currentActionContext !== null;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ============================================================================
|
|
389
|
+
// Type Checking
|
|
390
|
+
// ============================================================================
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Type check a value against a type, throwing if invalid
|
|
394
|
+
*/
|
|
395
|
+
export function typecheck<T>(
|
|
396
|
+
type: { is(v: unknown): v is T; name: string },
|
|
397
|
+
value: unknown,
|
|
398
|
+
): void {
|
|
399
|
+
if (!type.is(value)) {
|
|
400
|
+
throw new Error(
|
|
401
|
+
`[jotai-state-tree] Value ${JSON.stringify(value)} is not assignable to type '${type.name}'`,
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Try to resolve a value as an instance of a type
|
|
408
|
+
*/
|
|
409
|
+
export function tryResolve<T>(
|
|
410
|
+
type: { create(s: unknown): T; is(v: unknown): v is T },
|
|
411
|
+
value: unknown,
|
|
412
|
+
): T | undefined {
|
|
413
|
+
try {
|
|
414
|
+
if (type.is(value)) {
|
|
415
|
+
return value;
|
|
416
|
+
}
|
|
417
|
+
return type.create(value);
|
|
418
|
+
} catch {
|
|
419
|
+
return undefined;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// ============================================================================
|
|
424
|
+
// Utility: getType
|
|
425
|
+
// ============================================================================
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Get the type of a state tree node
|
|
429
|
+
*/
|
|
430
|
+
export function getType(target: unknown): unknown {
|
|
431
|
+
const node = getStateTreeNode(target);
|
|
432
|
+
return node.$type;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Check if a value is of a specific type
|
|
437
|
+
*/
|
|
438
|
+
export function isType(
|
|
439
|
+
value: unknown,
|
|
440
|
+
type: { is(v: unknown): boolean },
|
|
441
|
+
): boolean {
|
|
442
|
+
return type.is(value);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ============================================================================
|
|
446
|
+
// Utility: getChildType
|
|
447
|
+
// ============================================================================
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Get the type of a child property
|
|
451
|
+
*/
|
|
452
|
+
export function getChildType(target: unknown, propertyName: string): unknown {
|
|
453
|
+
const node = getStateTreeNode(target);
|
|
454
|
+
const type = node.$type as { properties?: Record<string, unknown> };
|
|
455
|
+
|
|
456
|
+
if (type.properties && propertyName in type.properties) {
|
|
457
|
+
return type.properties[propertyName];
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
throw new Error(
|
|
461
|
+
`[jotai-state-tree] Property '${propertyName}' not found on type '${(type as { name?: string }).name}'`,
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ============================================================================
|
|
466
|
+
// Apply Action
|
|
467
|
+
// ============================================================================
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Apply an action call to a target
|
|
471
|
+
*/
|
|
472
|
+
export function applyAction(
|
|
473
|
+
target: unknown,
|
|
474
|
+
action: ISerializedActionCall,
|
|
475
|
+
): unknown {
|
|
476
|
+
const node = getStateTreeNode(target);
|
|
477
|
+
|
|
478
|
+
// Navigate to the correct node using path
|
|
479
|
+
let currentNode = node;
|
|
480
|
+
if (action.path) {
|
|
481
|
+
const parts = action.path.split("/").filter(Boolean);
|
|
482
|
+
for (const part of parts) {
|
|
483
|
+
const child = currentNode.getChild(part);
|
|
484
|
+
if (!child) {
|
|
485
|
+
throw new Error(
|
|
486
|
+
`[jotai-state-tree] Invalid action path: ${action.path}`,
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
currentNode = child;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const instance = currentNode.getInstance() as Record<string, Function>;
|
|
494
|
+
if (typeof instance[action.name] !== "function") {
|
|
495
|
+
throw new Error(`[jotai-state-tree] Action '${action.name}' not found`);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return instance[action.name](...action.args);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// ============================================================================
|
|
502
|
+
// Escaping / Unescaping JSON Pointer
|
|
503
|
+
// ============================================================================
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Escape a JSON pointer segment
|
|
507
|
+
*/
|
|
508
|
+
export function escapeJsonPath(path: string): string {
|
|
509
|
+
return path.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Unescape a JSON pointer segment
|
|
514
|
+
*/
|
|
515
|
+
export function unescapeJsonPath(path: string): string {
|
|
516
|
+
return path.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Split a path into segments
|
|
521
|
+
*/
|
|
522
|
+
export function splitJsonPath(path: string): string[] {
|
|
523
|
+
return path.split("/").filter(Boolean).map(unescapeJsonPath);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Join path segments
|
|
528
|
+
*/
|
|
529
|
+
export function joinJsonPath(parts: string[]): string {
|
|
530
|
+
return parts.map(escapeJsonPath).join("/");
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// ============================================================================
|
|
534
|
+
// Dependency Tracking
|
|
535
|
+
// ============================================================================
|
|
536
|
+
|
|
537
|
+
interface DependencyTracker {
|
|
538
|
+
track(atom: unknown): void;
|
|
539
|
+
getTracked(): Set<unknown>;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
let currentTracker: DependencyTracker | null = null;
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Create a dependency tracker
|
|
546
|
+
*/
|
|
547
|
+
export function createDependencyTracker(): DependencyTracker {
|
|
548
|
+
const tracked = new Set<unknown>();
|
|
549
|
+
return {
|
|
550
|
+
track(atom: unknown) {
|
|
551
|
+
tracked.add(atom);
|
|
552
|
+
},
|
|
553
|
+
getTracked() {
|
|
554
|
+
return tracked;
|
|
555
|
+
},
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Run a function with dependency tracking
|
|
561
|
+
*/
|
|
562
|
+
export function withDependencyTracking<T>(
|
|
563
|
+
tracker: DependencyTracker,
|
|
564
|
+
fn: () => T,
|
|
565
|
+
): T {
|
|
566
|
+
const previous = currentTracker;
|
|
567
|
+
currentTracker = tracker;
|
|
568
|
+
try {
|
|
569
|
+
return fn();
|
|
570
|
+
} finally {
|
|
571
|
+
currentTracker = previous;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Track a dependency
|
|
577
|
+
*/
|
|
578
|
+
export function trackDependency(atom: unknown): void {
|
|
579
|
+
currentTracker?.track(atom);
|
|
580
|
+
}
|