@muze-labs/simplyflow 0.9.0 → 0.10.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/src/state.mjs CHANGED
@@ -1,1347 +1 @@
1
- import { DEP } from './symbols.mjs'
2
-
3
- const MAP_READS_KEY = new Set(['get', 'has'])
4
- const MAP_READS_ITERATION = new Set(['keys', 'values', 'entries', 'forEach', Symbol.iterator])
5
- const MAP_WRITES = new Set(['set', 'delete', 'clear'])
6
- const SET_WRITES = new Set(['add', 'delete', 'clear'])
7
- const SET_ITERATION_PROPERTIES = {
8
- entries: {},
9
- forEach: {},
10
- has: {},
11
- keys: {},
12
- values: {},
13
- [Symbol.iterator]: {}
14
- }
15
-
16
- function isObjectLike(value) {
17
- return value !== null && (typeof value === 'object' || typeof value === 'function')
18
- }
19
-
20
- /**
21
- * Returns true when value is a SimplyFlow signal proxy.
22
- *
23
- * @param {*} value Value to inspect.
24
- * @returns {boolean} True if value is a signal proxy, otherwise false.
25
- * @throws {never} Does not intentionally throw.
26
- */
27
- export function isSignal(value) {
28
- return Boolean(isObjectLike(value) && value[DEP.SIGNAL])
29
- }
30
-
31
- /**
32
- * Returns the raw target for a signal, or value unchanged when it is not a signal.
33
- *
34
- * @param {*} value Signal or ordinary value.
35
- * @returns {*} The signal target, or the original value.
36
- * @throws {never} Does not intentionally throw.
37
- */
38
- export function raw(value) {
39
- return isSignal(value) ? value[DEP.XRAY] : value
40
- }
41
-
42
- /**
43
- * Returns the existing signal proxy for value, if one has been registered.
44
- *
45
- * @param {*} value Raw target or signal proxy.
46
- * @returns {*} The existing signal proxy, the same signal when value is already a signal, or undefined.
47
- * @throws {never} Does not intentionally throw.
48
- */
49
- export function getSignal(value) {
50
- return isSignal(value) ? value : signals.get(value)
51
- }
52
-
53
- function targetSignal(target) {
54
- return signals.get(target)
55
- }
56
-
57
- function readTarget(target, property) {
58
- // Reflect.get() uses the proxy as the receiver for accessors. That breaks
59
- // native Map/Set size getters and class getters that rely on private fields.
60
- return target?.[property]
61
- }
62
-
63
- function bindMethod(target, receiver, value) {
64
- if (
65
- target instanceof HTMLElement
66
- || target instanceof Number
67
- || target instanceof String
68
- || target instanceof Boolean
69
- ) {
70
- return value.bind(target)
71
- }
72
-
73
- // For user-defined classes, bind to the signal so method bodies remain
74
- // reactive when they read or write public properties through `this`.
75
- return value.bind(receiver)
76
- }
77
-
78
- function collectRemovedArrayValues(target, nextLength) {
79
- const values = new Map()
80
- if (!Array.isArray(target) || nextLength >= target.length) {
81
- return values
82
- }
83
-
84
- for (let index = nextLength; index < target.length; index++) {
85
- if (Object.hasOwn(target, index)) {
86
- values.set(index, target[index])
87
- }
88
- }
89
- return values
90
- }
91
-
92
- function addArrayLengthChanges(context, target, oldLength, removedValues = new Map()) {
93
- if (!Array.isArray(target) || oldLength === target.length) {
94
- return
95
- }
96
-
97
- context.set(DEP.LENGTH, { was: oldLength, now: target.length })
98
- context.set(DEP.ITERATE, {})
99
-
100
- // Directly shrinking .length deletes indexes without going through the
101
- // proxy's delete trap. Notify listeners of those indexes explicitly.
102
- for (const [index, oldValue] of removedValues) {
103
- context.set(String(index), { delete: true, was: oldValue, now: undefined })
104
- }
105
- }
106
-
107
- function notifyContext(receiver, context) {
108
- if (context.size) {
109
- notifySet(receiver, context)
110
- }
111
- }
112
-
113
- function wrapArrayMethod(target, property, receiver, value) {
114
- return (...args) => {
115
- const oldLength = target.length
116
-
117
- // Native array methods must run with the proxy as `this`. That lets
118
- // their internal get/set/delete operations pass through the proxy traps.
119
- const result = value.apply(receiver, args)
120
-
121
- if (oldLength !== target.length) {
122
- notifySet(receiver, makeContext(DEP.LENGTH, { was: oldLength, now: target.length }))
123
- }
124
- return result
125
- }
126
- }
127
-
128
- function addMapWriteChanges(context, target, property, args, oldSize) {
129
- if (property === 'set') {
130
- const [key, nextValue] = args
131
- const hadKey = target.has(key)
132
- const oldValue = target.get(key)
133
-
134
- return () => {
135
- if (!hadKey || !Object.is(oldValue, nextValue)) {
136
- context.set(key, { was: oldValue, now: nextValue })
137
- // Existing value changes affect values(), entries(), forEach()
138
- // and direct iteration. The current dependency model uses one
139
- // iteration token, so keys() listeners are also conservatively
140
- // notified until Map iteration dependencies are split further.
141
- context.set(DEP.ITERATE, {})
142
- }
143
- if (!hadKey) {
144
- context.set(DEP.SIZE, { was: oldSize, now: target.size })
145
- }
146
- }
147
- }
148
-
149
- if (property === 'delete') {
150
- const [key] = args
151
- const hadKey = target.has(key)
152
- const oldValue = target.get(key)
153
-
154
- return () => {
155
- if (hadKey) {
156
- context.set(key, { delete: true, was: oldValue, now: undefined })
157
- context.set(DEP.SIZE, { was: oldSize, now: target.size })
158
- context.set(DEP.ITERATE, {})
159
- }
160
- }
161
- }
162
-
163
- if (property === 'clear') {
164
- const oldEntries = oldSize ? Array.from(target.entries()) : []
165
-
166
- return () => {
167
- if (oldEntries.length) {
168
- for (const [key, oldValue] of oldEntries) {
169
- context.set(key, { delete: true, was: oldValue, now: undefined })
170
- }
171
- context.set(DEP.SIZE, { was: oldSize, now: target.size })
172
- context.set(DEP.ITERATE, {})
173
- }
174
- }
175
- }
176
-
177
- return () => {}
178
- }
179
-
180
- function wrapMapMethod(target, property, receiver, value) {
181
- return (...args) => {
182
- if (MAP_READS_KEY.has(property)) {
183
- notifyGet(receiver, args[0])
184
- }
185
- if (MAP_READS_ITERATION.has(property)) {
186
- notifyGet(receiver, DEP.ITERATE)
187
- }
188
-
189
- const oldSize = target.size
190
- const context = new Map()
191
- const addChanges = MAP_WRITES.has(property)
192
- ? addMapWriteChanges(context, target, property, args, oldSize)
193
- : () => {}
194
- const result = value.apply(target, args)
195
-
196
- addChanges()
197
- notifyContext(receiver, context)
198
- return result
199
- }
200
- }
201
-
202
- function addSetWriteChanges(context, target, property, args, oldSize) {
203
- const [value] = args
204
- const hadValue = property === 'add' || property === 'delete'
205
- ? target.has(value)
206
- : false
207
-
208
- return () => {
209
- const changed = property === 'clear'
210
- ? oldSize > 0
211
- : target.size !== oldSize || (property === 'delete' && hadValue)
212
-
213
- if (!changed) {
214
- return
215
- }
216
-
217
- context.set(DEP.SIZE, { was: oldSize, now: target.size })
218
-
219
- // Set.has(value) currently tracks at method level rather than per value.
220
- // Notify all Set read methods after real writes so this remains correct,
221
- // but suppress add(existing), delete(missing), and clear(empty).
222
- for (const prop of Reflect.ownKeys(SET_ITERATION_PROPERTIES)) {
223
- context.set(prop, {})
224
- }
225
- }
226
- }
227
-
228
- function wrapSetMethod(target, property, receiver, value) {
229
- return (...args) => {
230
- const oldSize = target.size
231
- const context = new Map()
232
- const addChanges = SET_WRITES.has(property)
233
- ? addSetWriteChanges(context, target, property, args, oldSize)
234
- : () => {}
235
- const result = value.apply(target, args)
236
-
237
- addChanges()
238
- notifyContext(receiver, context)
239
- return result
240
- }
241
- }
242
-
243
- function propertyValueChanged(descriptor, oldDescriptor, oldValue, newDescriptor, newValue) {
244
- return (
245
- (Object.hasOwn(descriptor, 'value') && !Object.is(oldValue, newValue))
246
- || (Object.hasOwn(descriptor, 'get') && oldDescriptor?.get !== newDescriptor?.get)
247
- || (Object.hasOwn(descriptor, 'set') && oldDescriptor?.set !== newDescriptor?.set)
248
- )
249
- }
250
-
251
- const signalHandler = {
252
- get(target, property, receiver) {
253
- const value = readTarget(target, property)
254
- notifyGet(receiver, property)
255
-
256
- if (typeof value === 'function') {
257
- if (Array.isArray(target)) {
258
- return wrapArrayMethod(target, property, receiver, value)
259
- }
260
- if (target instanceof Map) {
261
- return wrapMapMethod(target, property, receiver, value)
262
- }
263
- if (target instanceof Set) {
264
- return wrapSetMethod(target, property, receiver, value)
265
- }
266
- return bindMethod(target, receiver, value)
267
- }
268
-
269
- return isObjectLike(value) ? signal(value) : value
270
- },
271
-
272
- set(target, property, value, receiver) {
273
- const hadOwn = Object.hasOwn(target, property)
274
- const oldLength = Array.isArray(target) ? target.length : undefined
275
- const removedValues = property === DEP.LENGTH
276
- ? collectRemovedArrayValues(target, Number(value))
277
- : new Map()
278
- const oldValue = target[property]
279
-
280
- target[property] = value
281
-
282
- const hasOwn = Object.hasOwn(target, property)
283
- const newValue = target[property]
284
- const context = new Map()
285
-
286
- if (!Object.is(oldValue, newValue) || (!hadOwn && hasOwn)) {
287
- context.set(property, { was: oldValue, now: newValue })
288
- }
289
- if (!hadOwn && hasOwn) {
290
- context.set(DEP.ITERATE, {})
291
- }
292
-
293
- addArrayLengthChanges(context, target, oldLength, removedValues)
294
- notifyContext(receiver, context)
295
- return true
296
- },
297
-
298
- has(target, property) {
299
- // The has trap has no receiver argument. Look up the stable proxy so
300
- // `property in signal` can still be tracked reactively.
301
- const receiver = targetSignal(target)
302
- if (receiver) {
303
- notifyGet(receiver, property)
304
- }
305
- return Reflect.has(target, property)
306
- },
307
-
308
- deleteProperty(target, property) {
309
- const hadOwn = Object.hasOwn(target, property)
310
- if (!hadOwn) {
311
- return true
312
- }
313
-
314
- const oldValue = target[property]
315
- const oldLength = Array.isArray(target) ? target.length : undefined
316
- const result = Reflect.deleteProperty(target, property)
317
- if (!result) {
318
- return result
319
- }
320
-
321
- const receiver = targetSignal(target)
322
- const context = makeContext(property, { delete: true, was: oldValue, now: undefined })
323
- context.set(DEP.ITERATE, { delete: true, property })
324
- addArrayLengthChanges(context, target, oldLength)
325
- notifySet(receiver, context)
326
- return result
327
- },
328
-
329
- defineProperty(target, property, descriptor) {
330
- const hadOwn = Object.hasOwn(target, property)
331
- const oldDescriptor = Object.getOwnPropertyDescriptor(target, property)
332
- const oldValue = target[property]
333
- const oldLength = Array.isArray(target) ? target.length : undefined
334
- const removedValues = property === DEP.LENGTH && Object.hasOwn(descriptor, 'value')
335
- ? collectRemovedArrayValues(target, Number(descriptor.value))
336
- : new Map()
337
-
338
- const result = Reflect.defineProperty(target, property, descriptor)
339
- if (!result) {
340
- return result
341
- }
342
-
343
- const hasOwn = Object.hasOwn(target, property)
344
- const newDescriptor = Object.getOwnPropertyDescriptor(target, property)
345
- const newValue = target[property]
346
- const context = new Map()
347
-
348
- if (!hadOwn && hasOwn) {
349
- context.set(property, { was: oldValue, now: newValue })
350
- context.set(DEP.ITERATE, {})
351
- } else if (hadOwn && hasOwn) {
352
- if (propertyValueChanged(descriptor, oldDescriptor, oldValue, newDescriptor, newValue)) {
353
- context.set(property, { was: oldValue, now: newValue })
354
- }
355
- if (oldDescriptor?.enumerable !== newDescriptor?.enumerable) {
356
- context.set(DEP.ITERATE, {})
357
- }
358
- }
359
-
360
- addArrayLengthChanges(context, target, oldLength, removedValues)
361
- notifyContext(targetSignal(target), context)
362
- return result
363
- },
364
-
365
- ownKeys(target) {
366
- const receiver = targetSignal(target)
367
- notifyGet(receiver, DEP.ITERATE)
368
- return Reflect.ownKeys(target)
369
- }
370
- }
371
-
372
- /**
373
- * Low-level registry for signal proxies and effect result signals.
374
- *
375
- * @type {WeakMap<object|Function, Proxy>}
376
- * @returns {WeakMap<object|Function, Proxy>} Maps raw targets to signals, and effect functions to result signals.
377
- * @throws {never} Reading this property does not throw.
378
- * @deprecated Prefer createSignal(), getSignal(), registerSignal(), isSignal(), and raw().
379
- */
380
- export const signals = new WeakMap()
381
-
382
- function assertSignalTarget(value, name) {
383
- if (!isObjectLike(value)) {
384
- throw new TypeError(
385
- `simplyflow/state: ${name}() expects an object, array, Map, Set, class instance, function, or DOM node; received ${typeof value}`
386
- )
387
- }
388
- }
389
-
390
- function assertProxyHandler(handler, name) {
391
- if (!handler || typeof handler !== 'object') {
392
- throw new TypeError(`simplyflow/state: ${name}() expects a Proxy handler object`)
393
- }
394
- }
395
-
396
- function signalProxyHandler(handler) {
397
- // All signal implementations must answer these two private symbol reads in
398
- // the same way. Keeping that boilerplate here lets custom signal handlers
399
- // focus on their own get/set/observe behavior.
400
- return {
401
- ...handler,
402
- get(target, property, receiver) {
403
- if (property === DEP.XRAY) {
404
- return target
405
- }
406
- if (property === DEP.SIGNAL) {
407
- return true
408
- }
409
- if (handler.get) {
410
- return handler.get(target, property, receiver)
411
- }
412
- return readTarget(target, property)
413
- }
414
- }
415
- }
416
-
417
- /**
418
- * Registers a custom signal proxy for a raw target.
419
- *
420
- * @param {object|Function} target Raw object, function, class instance, collection, or DOM node to register.
421
- * @param {Proxy} proxy Signal proxy that represents target.
422
- * @returns {Proxy} The registered proxy.
423
- * @throws {TypeError} If target is not object-like or proxy is not a signal.
424
- * @throws {Error} If target is already registered with a different signal.
425
- */
426
- export function registerSignal(target, proxy) {
427
- const rawTarget = raw(target)
428
- assertSignalTarget(rawTarget, 'registerSignal')
429
-
430
- if (!isSignal(proxy)) {
431
- throw new TypeError('simplyflow/state: registerSignal() expects a signal proxy')
432
- }
433
-
434
- const existing = signals.get(rawTarget)
435
- if (existing && existing !== proxy) {
436
- throw new Error('simplyflow/state: registerSignal() target already has a different signal')
437
- }
438
-
439
- signals.set(rawTarget, proxy)
440
- return proxy
441
- }
442
-
443
- /**
444
- * Creates or returns a signal proxy using a custom Proxy handler.
445
- *
446
- * @param {object|Function} target Raw object, function, class instance, collection, or DOM node to wrap.
447
- * @param {ProxyHandler<object>} [handler={}] Custom proxy traps for the signal.
448
- * @param {Function} [init] Optional initializer called once with (target, proxy).
449
- * @returns {Proxy} Existing or newly created signal proxy for target.
450
- * @throws {TypeError} If target is not object-like, handler is not an object, or init is not a function.
451
- * @throws {*} Re-throws errors from init.
452
- */
453
- export function createSignal(target, handler = {}, init) {
454
- assertSignalTarget(target, 'createSignal')
455
- assertProxyHandler(handler, 'createSignal')
456
- if (init !== undefined && typeof init !== 'function') {
457
- throw new TypeError('simplyflow/state: createSignal() expects init to be a function')
458
- }
459
-
460
- if (isSignal(target)) {
461
- return target
462
- }
463
-
464
- const existing = getSignal(target)
465
- if (existing) {
466
- return existing
467
- }
468
-
469
- const proxy = new Proxy(target, signalProxyHandler(handler))
470
- registerSignal(target, proxy)
471
- init?.(target, proxy)
472
- return proxy
473
- }
474
-
475
- /**
476
- * Creates a transparent reactive proxy for an object, collection, class instance, DOM node, or function.
477
- *
478
- * @param {object|Function} [value={}] Target to wrap.
479
- * @returns {Proxy} Existing or newly created signal proxy for value.
480
- * @throws {TypeError} If value is not object-like.
481
- */
482
- export function signal(value = {}) {
483
- if (!isObjectLike(value)) {
484
- throw new TypeError(
485
- `simplyflow/state: signal() expects an object, array, Map, Set, class instance, or function; received ${typeof value}`
486
- )
487
- }
488
- return createSignal(value, signalHandler)
489
- }
490
-
491
- let tracers = []
492
- let tracing = false
493
-
494
- /**
495
- * Runs a traced function, or returns effects currently depending on a signal property.
496
- *
497
- * @param {Function|Proxy} target Function to run with tracing enabled, or signal to inspect.
498
- * @param {string|symbol|number} [prop] Signal property whose listeners should be returned.
499
- * @returns {*} Result of target() for traced functions, or an array of listener descriptions.
500
- * @throws {TypeError} If inspecting listeners and target is not a signal.
501
- * @throws {*} Re-throws errors from target() when tracing a function.
502
- */
503
- export function trace(target, prop) {
504
- if (typeof target === 'function') {
505
- tracing = true
506
- try {
507
- return target()
508
- } finally {
509
- tracing = false
510
- }
511
- }
512
-
513
- if (!isSignal(target)) {
514
- throw new TypeError('simplyflow/state: trace() expects either a function or a signal')
515
- }
516
-
517
- return getListeners(target, prop).map(listener => ({
518
- effect: listener.effectType,
519
- fn: listener.effectFunction,
520
- signal: signals.get(listener.effectFunction)
521
- }))
522
- }
523
-
524
- /**
525
- * Adds an observer for dependency tracking events emitted inside trace(fn).
526
- *
527
- * @param {{get?: Function, set?: Function}} tracer Object with get and/or set callback functions.
528
- * @returns {void}
529
- * @throws {TypeError} If tracer is not an object.
530
- * @throws {Error} If tracer has no callbacks, or if a provided callback is not a function.
531
- */
532
- export function addTracer(tracer) {
533
- if (!tracer || typeof tracer !== 'object') {
534
- throw new TypeError('simplyflow/state: addTracer() expects a tracer object')
535
- }
536
- if (!tracer.get && !tracer.set) {
537
- throw new Error('simplyflow/state: addTracer: missing "get" or "set" property in tracer')
538
- }
539
- if (tracer.get && typeof tracer.get !== 'function') {
540
- throw new Error('simplyflow/state: addTracer: "get" is not a function')
541
- }
542
- if (tracer.set && typeof tracer.set !== 'function') {
543
- throw new Error('simplyflow/state: addTracer: "set" is not a function')
544
- }
545
- tracers.push(tracer)
546
- }
547
-
548
- function callTracers(kind, ...params) {
549
- for (const tracer of tracers) {
550
- tracer[kind]?.(...params)
551
- }
552
- }
553
-
554
- let batchedListeners = new Set()
555
- let batchDepth = 0
556
-
557
- /**
558
- * Triggers effects that depend on the changed signal properties in context.
559
- *
560
- * @param {Proxy} self Signal whose properties changed.
561
- * @param {Map<string|symbol|number, object>} [context=new Map()] Change map, usually created with makeContext().
562
- * @returns {void}
563
- * @throws {TypeError} If self is not a signal or context is not a Map.
564
- */
565
- export function notifySet(self, context = new Map()) {
566
- if (!isSignal(self)) {
567
- throw new TypeError('simplyflow/state: notifySet() expects a signal as first argument')
568
- }
569
- if (!(context instanceof Map)) {
570
- throw new TypeError('simplyflow/state: notifySet() expects context to be a Map; use makeContext()')
571
- }
572
-
573
- const listeners = new Set()
574
- context.forEach((change, property) => {
575
- for (const listener of listenersFor(self, property)) {
576
- // Avoid makeContext() here: notifySet() is a hot path and this loop
577
- // can run once per changed property per listener. Writing directly
578
- // to the listener context keeps object/Map keys intact and avoids
579
- // creating a short-lived Map for every listener notification.
580
- addContextChange(listener, property, change)
581
- listeners.add(listener)
582
- }
583
- })
584
-
585
- if (!listeners.size) {
586
- return
587
- }
588
-
589
- if (batchDepth) {
590
- for (const listener of listeners) {
591
- batchedListeners.add(listener)
592
- }
593
- return
594
- }
595
-
596
- runListeners(listeners, self, context)
597
- }
598
-
599
- /**
600
- * Creates a normalized change context map for notifySet().
601
- *
602
- * @param {Map|object|string|symbol|number} property Property name, property-to-change object, or existing context Map.
603
- * @param {object} [change] Change metadata when property names a single changed property.
604
- * @returns {Map<string|symbol|number, object>} Normalized change context.
605
- * @throws {never} Does not intentionally throw.
606
- */
607
- export function makeContext(property, change) {
608
- const context = new Map()
609
-
610
- if (property instanceof Map) {
611
- property.forEach((change, prop) => context.set(prop, change))
612
- return context
613
- }
614
-
615
- if (property !== null && typeof property === 'object') {
616
- for (const prop of Reflect.ownKeys(property)) {
617
- context.set(prop, property[prop])
618
- }
619
- } else {
620
- context.set(property, change)
621
- }
622
- return context
623
- }
624
-
625
- function addContextChange(listener, property, change) {
626
- if (!listener.context) {
627
- listener.context = new Map()
628
- }
629
- listener.context.set(property, change)
630
- listener.needsUpdate = true
631
- }
632
-
633
- function clearContext(listener) {
634
- delete listener.context
635
- delete listener.needsUpdate
636
- }
637
-
638
- /**
639
- * Records a dependency on self[property] for the currently running effect.
640
- *
641
- * @param {Proxy} self Signal being read.
642
- * @param {string|symbol|number} property Property being read.
643
- * @returns {void}
644
- * @throws {never} Does not intentionally throw.
645
- */
646
- export function notifyGet(self, property) {
647
- const currentCompute = computeStack[computeStack.length - 1]
648
- if (!currentCompute || currentCompute.skipDependency?.(self, property)) {
649
- return
650
- }
651
-
652
- if (tracing && tracers.length) {
653
- callTracers('get', self, property)
654
- }
655
- setListeners(self, property, currentCompute)
656
- }
657
-
658
- const listenersMap = new WeakMap()
659
- const computeMap = new WeakMap()
660
-
661
- const emptyListeners = new Set()
662
-
663
- function listenersFor(self, property) {
664
- return listenersMap.get(self)?.get(property) || emptyListeners
665
- }
666
-
667
- function getListeners(self, property) {
668
- return Array.from(listenersFor(self, property))
669
- }
670
-
671
- function setListeners(self, property, compute) {
672
- if (!listenersMap.has(self)) {
673
- listenersMap.set(self, new Map())
674
- }
675
- const listeners = listenersMap.get(self)
676
- if (!listeners.has(property)) {
677
- listeners.set(property, new Set())
678
- }
679
- listeners.get(property).add(compute)
680
-
681
- if (!computeMap.has(compute)) {
682
- computeMap.set(compute, new Map())
683
- }
684
- const dependencies = computeMap.get(compute)
685
- if (!dependencies.has(property)) {
686
- dependencies.set(property, new Set())
687
- }
688
- dependencies.get(property).add(self)
689
- }
690
-
691
- function clearListeners(compute) {
692
- const dependencies = computeMap.get(compute)
693
- if (!dependencies) {
694
- return
695
- }
696
-
697
- dependencies.forEach((signals, property) => {
698
- signals.forEach(signal => {
699
- const listeners = listenersMap.get(signal)
700
- listeners?.get(property)?.delete(compute)
701
- })
702
- })
703
-
704
- computeMap.delete(compute)
705
- }
706
-
707
- const computeStack = []
708
- const effectStack = []
709
- const signalStack = []
710
- const effectMap = new WeakMap()
711
-
712
- function assertFunction(fn, name) {
713
- if (typeof fn !== 'function') {
714
- throw new TypeError(`simplyflow/state: ${name}() expects a function`)
715
- }
716
- }
717
-
718
- function assertNotRecursive(fn) {
719
- if (effectStack.includes(fn)) {
720
- throw new Error('Recursive update() call', { cause: fn })
721
- }
722
- }
723
-
724
- function effectSignal(fn) {
725
- let connectedSignal = signals.get(fn)
726
- if (!connectedSignal) {
727
- connectedSignal = signal({ current: null })
728
- signals.set(fn, connectedSignal)
729
- }
730
- return connectedSignal
731
- }
732
-
733
- function setEffectResult(connectedSignal, result) {
734
- if (result instanceof Promise) {
735
- result.then(value => {
736
- connectedSignal.current = value
737
- })
738
- } else {
739
- connectedSignal.current = result
740
- }
741
- }
742
-
743
- function runTracked(compute, connectedSignal, fn, effectType, args = [compute, computeStack, signalStack]) {
744
- if (signalStack.includes(connectedSignal)) {
745
- throw new Error('Cyclical dependency in update() call', { cause: fn })
746
- }
747
-
748
- clearListeners(compute)
749
- compute.effectFunction = fn
750
- compute.effectType = effectType
751
- computeStack.push(compute)
752
- signalStack.push(connectedSignal)
753
-
754
- let result
755
- try {
756
- result = fn(...args)
757
- } finally {
758
- computeStack.pop()
759
- signalStack.pop()
760
- setEffectResult(connectedSignal, result)
761
- }
762
- }
763
-
764
- function runListeners(listeners, signal, context) {
765
- const currentEffect = computeStack[computeStack.length - 1]
766
-
767
- for (const listener of listeners) {
768
- if (listener !== currentEffect && listener?.needsUpdate) {
769
- if (listener.scheduleClock) {
770
- listener.scheduleClock()
771
- } else {
772
- if (signal && tracing && tracers.length) {
773
- callTracers('set', signal, context, listener)
774
- }
775
- listener()
776
- }
777
- }
778
- clearContext(listener)
779
- }
780
- }
781
-
782
- /**
783
- * Runs fn immediately and reruns it synchronously when any signal property it reads changes.
784
- *
785
- * @param {Function} fn Effect function to run and track.
786
- * @returns {Proxy} Signal whose current property contains the latest effect result.
787
- * @throws {TypeError} If fn is not a function.
788
- * @throws {Error} If fn is already running recursively or creates a cyclic dependency.
789
- * @throws {*} Re-throws errors from fn during the initial run.
790
- */
791
- export function effect(fn) {
792
- assertFunction(fn, 'effect')
793
- assertNotRecursive(fn)
794
- effectStack.push(fn)
795
-
796
- const connectedSignal = effectSignal(fn)
797
- const compute = function computeEffect() {
798
- runTracked(compute, connectedSignal, fn, effect)
799
- }
800
- compute.fn = fn
801
- effectMap.set(connectedSignal, compute)
802
-
803
- compute()
804
- return connectedSignal
805
- }
806
-
807
- /**
808
- * Stops an effect, clears its dependencies, and releases its reusable function mapping.
809
- *
810
- * @param {Proxy} connectedSignal Signal returned by effect(), throttledEffect(), or clockEffect().
811
- * @returns {void}
812
- * @throws {TypeError} If connectedSignal is not a signal.
813
- */
814
- export function destroy(connectedSignal) {
815
- if (!isSignal(connectedSignal)) {
816
- throw new TypeError('simplyflow/state: destroy() expects an effect signal')
817
- }
818
-
819
- const compute = effectMap.get(connectedSignal)
820
- if (!compute) {
821
- return
822
- }
823
-
824
- compute.destroy?.()
825
- clearListeners(compute)
826
-
827
- if (compute.fn) {
828
- signals.delete(compute.fn)
829
- const index = effectStack.findIndex(fn => fn === compute.fn)
830
- if (index !== -1) {
831
- effectStack.splice(index, 1)
832
- }
833
- }
834
-
835
- effectMap.delete(connectedSignal)
836
- }
837
-
838
- /**
839
- * Runs fn while deferring effect execution until the outermost batch finishes.
840
- *
841
- * @param {Function} fn Function to run inside the batch.
842
- * @returns {*} The value returned by fn.
843
- * @throws {TypeError} If fn is not a function.
844
- * @throws {*} Re-throws errors from fn.
845
- */
846
- export function batch(fn) {
847
- assertFunction(fn, 'batch')
848
- batchDepth++
849
-
850
- let result
851
- try {
852
- result = fn()
853
- } finally {
854
- const finish = () => {
855
- batchDepth--
856
- if (!batchDepth) {
857
- runBatchedListeners()
858
- }
859
- }
860
-
861
- if (result instanceof Promise) {
862
- result.then(finish, finish)
863
- } else {
864
- finish()
865
- }
866
- }
867
- return result
868
- }
869
-
870
- function runBatchedListeners() {
871
- const listeners = batchedListeners
872
- batchedListeners = new Set()
873
-
874
- // Batched clocked dependency changes must be marked before clock ticks are
875
- // flushed. Otherwise batch(() => { clock.time++; source.value++ }) would see
876
- // the tick first and leave the source change pending until the next tick.
877
- const clocked = new Set()
878
- const ready = new Set()
879
- for (const listener of listeners) {
880
- if (listener.scheduleClock) {
881
- clocked.add(listener)
882
- } else {
883
- ready.add(listener)
884
- }
885
- }
886
-
887
- runListeners(clocked)
888
- runListeners(ready)
889
- }
890
-
891
- /**
892
- * Runs fn as an effect, throttling reruns to at most once per throttleTime milliseconds.
893
- *
894
- * @param {Function} fn Effect function to run and track.
895
- * @param {number} throttleTime Minimum time in milliseconds between reruns after the initial run.
896
- * @returns {Proxy} Signal whose current property contains the latest effect result.
897
- * @throws {TypeError} If fn is not a function or throttleTime is not a non-negative finite number.
898
- * @throws {Error} If fn is already running recursively or creates a cyclic dependency.
899
- * @throws {*} Re-throws errors from fn during the initial run.
900
- */
901
- export function throttledEffect(fn, throttleTime) {
902
- assertFunction(fn, 'throttledEffect')
903
- if (!Number.isFinite(throttleTime) || throttleTime < 0) {
904
- throw new TypeError('simplyflow/state: throttledEffect() expects throttleTime to be a non-negative number')
905
- }
906
- assertNotRecursive(fn)
907
- effectStack.push(fn)
908
-
909
- const connectedSignal = effectSignal(fn)
910
- let throttledUntil = 0
911
- let hasChange = true
912
- let timeout = null
913
-
914
- const compute = function computeEffect() {
915
- const now = Date.now()
916
- if (throttledUntil > now) {
917
- hasChange = true
918
- schedule()
919
- return
920
- }
921
-
922
- runTracked(compute, connectedSignal, fn, throttledEffect)
923
- hasChange = false
924
- throttledUntil = Date.now() + throttleTime
925
- schedule()
926
- }
927
-
928
- function schedule() {
929
- if (timeout) {
930
- return
931
- }
932
-
933
- const delay = Math.max(0, throttledUntil - Date.now())
934
- timeout = globalThis.setTimeout(() => {
935
- timeout = null
936
- if (hasChange) {
937
- compute()
938
- }
939
- }, delay)
940
- }
941
-
942
- compute.fn = fn
943
- compute.destroy = () => {
944
- if (timeout) {
945
- globalThis.clearTimeout(timeout)
946
- timeout = null
947
- }
948
- hasChange = false
949
- }
950
- effectMap.set(connectedSignal, compute)
951
-
952
- compute()
953
- return connectedSignal
954
- }
955
-
956
- const clockQueues = new WeakMap()
957
-
958
- function readClockTime(clock) {
959
- return raw(clock).time
960
- }
961
-
962
- function getClockQueue(clock) {
963
- if (!clockQueues.has(clock)) {
964
- const queue = {
965
- clock,
966
- effects: new Set(),
967
- pending: new Set(),
968
- time: readClockTime(clock)
969
- }
970
-
971
- // A clock has exactly one listener on `.time`. Ordinary dependency
972
- // changes add clock effects to `pending`; the shared tick listener only
973
- // flushes that pending set after time increases. This avoids waking every
974
- // clockEffect on every clock tick just to discover most of them have no
975
- // pending changes.
976
- queue.tick = function tickClockEffects() {
977
- const time = readClockTime(clock)
978
- if (time <= queue.time) {
979
- return
980
- }
981
-
982
- queue.time = time
983
- const pending = Array.from(queue.pending)
984
- queue.pending.clear()
985
-
986
- for (const compute of pending) {
987
- compute.clockPending = false
988
- if (queue.effects.has(compute)) {
989
- compute()
990
- }
991
- }
992
- }
993
- queue.tick.effectFunction = queue.tick
994
- queue.tick.effectType = clockEffect
995
- setListeners(clock, 'time', queue.tick)
996
- clockQueues.set(clock, queue)
997
- }
998
-
999
- return clockQueues.get(clock)
1000
- }
1001
-
1002
- function detachClockEffect(compute) {
1003
- const queue = compute.clockQueue
1004
- if (!queue) {
1005
- return
1006
- }
1007
-
1008
- queue.pending.delete(compute)
1009
- queue.effects.delete(compute)
1010
- if (!queue.effects.size) {
1011
- clearListeners(queue.tick)
1012
- clockQueues.delete(queue.clock)
1013
- }
1014
- }
1015
-
1016
- /**
1017
- * Tracks fn like an effect, but recomputes only after clock.time advances.
1018
- *
1019
- * @param {Function} fn Effect function to run and track.
1020
- * @param {{time: number}} clock Clock object controlling when pending changes recompute.
1021
- * @returns {Proxy} Signal whose current property contains the latest effect result.
1022
- * @throws {TypeError} If fn is not a function or clock lacks a numeric time property.
1023
- * @throws {*} Re-throws errors from fn during the initial run.
1024
- */
1025
- export function clockEffect(fn, clock) {
1026
- assertFunction(fn, 'clockEffect')
1027
- if (!clock || typeof clock !== 'object' || typeof raw(clock).time !== 'number') {
1028
- throw new TypeError('simplyflow/state: clockEffect() expects a clock object with a numeric .time property')
1029
- }
1030
-
1031
- const clockSignal = isSignal(clock) ? clock : signal(raw(clock))
1032
- const connectedSignal = effectSignal(fn)
1033
- const queue = getClockQueue(clockSignal)
1034
-
1035
- const compute = function computeEffect() {
1036
- clearListeners(compute)
1037
- compute.effectFunction = fn
1038
- compute.effectType = clockEffect
1039
- computeStack.push(compute)
1040
-
1041
- let result
1042
- try {
1043
- result = fn(compute, computeStack)
1044
- } finally {
1045
- computeStack.pop()
1046
- setEffectResult(connectedSignal, result)
1047
- }
1048
- }
1049
-
1050
- compute.fn = fn
1051
- compute.clockQueue = queue
1052
- compute.skipDependency = (self, property) => self === clockSignal && property === 'time'
1053
- compute.scheduleClock = () => {
1054
- if (!compute.clockPending) {
1055
- compute.clockPending = true
1056
- queue.pending.add(compute)
1057
- }
1058
- }
1059
- compute.destroy = () => detachClockEffect(compute)
1060
-
1061
- queue.effects.add(compute)
1062
- effectMap.set(connectedSignal, compute)
1063
-
1064
- compute()
1065
- return connectedSignal
1066
- }
1067
-
1068
- /**
1069
- * Runs fn without recording signal reads as dependencies for the current effect.
1070
- *
1071
- * @param {Function} fn Function to run without dependency tracking.
1072
- * @returns {*} The value returned by fn.
1073
- * @throws {TypeError} If fn is not a function.
1074
- * @throws {*} Re-throws errors from fn.
1075
- */
1076
- export function untracked(fn) {
1077
- assertFunction(fn, 'untracked')
1078
- const index = computeStack.length - 1
1079
- const current = computeStack[index]
1080
- computeStack[index] = false
1081
- try {
1082
- return fn()
1083
- } finally {
1084
- computeStack[index] = current
1085
- }
1086
- }
1087
-
1088
- function cloneOptions(options) {
1089
- if (typeof options === 'boolean') {
1090
- return { deep: options }
1091
- }
1092
- if (options === undefined) {
1093
- return { deep: true }
1094
- }
1095
- if (!options || typeof options !== 'object') {
1096
- throw new TypeError('simplyflow/state: clone() expects options to be a boolean or object')
1097
- }
1098
- return { deep: options.deep !== false }
1099
- }
1100
-
1101
- function typeName(value) {
1102
- return value?.constructor?.name || Object.prototype.toString.call(value).slice(8, -1)
1103
- }
1104
-
1105
- function isPlainObject(value) {
1106
- const prototype = Object.getPrototypeOf(value)
1107
- return prototype === Object.prototype || prototype === null
1108
- }
1109
-
1110
- function isTypedArray(value) {
1111
- return ArrayBuffer.isView(value) && !(value instanceof DataView)
1112
- }
1113
-
1114
- function isIntegerKey(property) {
1115
- if (typeof property !== 'string' || property === '') {
1116
- return false
1117
- }
1118
- const index = Number(property)
1119
- return Number.isInteger(index) && index >= 0 && String(index) === property
1120
- }
1121
-
1122
- function hasToClone(value) {
1123
- return typeof value.toClone === 'function'
1124
- }
1125
-
1126
- function cannotClone(value, path) {
1127
- throw new TypeError(
1128
- `simplyflow/state: clone() cannot clone ${typeName(value)} at ${path}; add a toClone() method for custom objects`
1129
- )
1130
- }
1131
-
1132
- function cloneDescriptorProperties(source, result, cloneValue, skip = () => false) {
1133
- const descriptors = Object.getOwnPropertyDescriptors(source)
1134
-
1135
- for (const key of Reflect.ownKeys(descriptors)) {
1136
- if (skip(key)) {
1137
- delete descriptors[key]
1138
- continue
1139
- }
1140
-
1141
- const descriptor = descriptors[key]
1142
- // Accessor descriptors may hide state in closures or private fields.
1143
- // Copying the accessor would not necessarily create an independent clone,
1144
- // and reading it would execute user code. Custom objects that need this
1145
- // should expose toClone() so they control how hidden state is copied.
1146
- if (!Object.hasOwn(descriptor, 'value')) {
1147
- cannotClone(source, String(key))
1148
- }
1149
- descriptor.value = cloneValue(descriptor.value, String(key))
1150
- }
1151
-
1152
- Object.defineProperties(result, descriptors)
1153
- return result
1154
- }
1155
-
1156
- function cloneArrayBuffer(value) {
1157
- return value.slice(0)
1158
- }
1159
-
1160
- function cloneSharedArrayBuffer(value) {
1161
- const result = new SharedArrayBuffer(value.byteLength)
1162
- new Uint8Array(result).set(new Uint8Array(value))
1163
- return result
1164
- }
1165
-
1166
- function cloneErrorObject(value, cloneValue, path) {
1167
- const standardErrors = new Set([
1168
- Error,
1169
- EvalError,
1170
- RangeError,
1171
- ReferenceError,
1172
- SyntaxError,
1173
- TypeError,
1174
- URIError,
1175
- typeof AggregateError === 'undefined' ? undefined : AggregateError
1176
- ])
1177
-
1178
- if (!standardErrors.has(value.constructor)) {
1179
- cannotClone(value, path)
1180
- }
1181
-
1182
- const options = Object.hasOwn(value, 'cause')
1183
- ? { cause: cloneValue(value.cause, 'cause') }
1184
- : undefined
1185
-
1186
- if (typeof AggregateError !== 'undefined' && value instanceof AggregateError) {
1187
- const errors = Array.from(value.errors || [], (error, index) => cloneValue(error, `errors.${index}`))
1188
- return new AggregateError(errors, value.message, options)
1189
- }
1190
-
1191
- return new value.constructor(value.message, options)
1192
- }
1193
-
1194
- /**
1195
- * Creates a non-reactive clone of a value or signal target.
1196
- *
1197
- * Deep-clones by default so nested signal data is not shared with the source. Built-in cloneable objects use their
1198
- * native representation. Custom objects must provide toClone(); otherwise clone() throws instead of returning a
1199
- * shared reference or copying only public properties.
1200
- *
1201
- * @param {*} value Value or signal target to clone.
1202
- * @param {boolean|{deep?: boolean}} [options] false or { deep: false } keeps legacy shallow top-level cloning.
1203
- * @returns {*} Non-reactive clone of value.
1204
- * @throws {TypeError} If options are invalid, an unsupported object is encountered, an accessor property is found, or toClone() returns the original object.
1205
- * @throws {*} Re-throws errors from custom toClone() methods.
1206
- */
1207
- export function clone(value, options) {
1208
- const { deep } = cloneOptions(options)
1209
- const seen = new Map()
1210
-
1211
- function cloneChild(value, path) {
1212
- return deep ? cloneValue(value, path) : raw(value)
1213
- }
1214
-
1215
- function cloneValue(value, path = 'value') {
1216
- const source = raw(value)
1217
-
1218
- if (!isObjectLike(source)) {
1219
- return source
1220
- }
1221
- if (seen.has(source)) {
1222
- return seen.get(source)
1223
- }
1224
- if (hasToClone(source)) {
1225
- const result = raw(source.toClone())
1226
- if (Object.is(result, source)) {
1227
- throw new TypeError(`simplyflow/state: clone() toClone() returned the original object at ${path}`)
1228
- }
1229
- seen.set(source, result)
1230
- return result
1231
- }
1232
-
1233
- if (Array.isArray(source)) {
1234
- const result = new Array(source.length)
1235
- seen.set(source, result)
1236
- return cloneDescriptorProperties(source, result, cloneChild, key => key === 'length')
1237
- }
1238
-
1239
- if (isPlainObject(source)) {
1240
- const result = Object.create(Object.getPrototypeOf(source))
1241
- seen.set(source, result)
1242
- return cloneDescriptorProperties(source, result, cloneChild)
1243
- }
1244
-
1245
- if (source instanceof Map) {
1246
- const result = new Map()
1247
- seen.set(source, result)
1248
- source.forEach((mapValue, mapKey) => {
1249
- result.set(cloneChild(mapKey, 'map key'), cloneChild(mapValue, 'map value'))
1250
- })
1251
- return cloneDescriptorProperties(source, result, cloneChild)
1252
- }
1253
-
1254
- if (source instanceof Set) {
1255
- const result = new Set()
1256
- seen.set(source, result)
1257
- source.forEach(setValue => result.add(cloneChild(setValue, 'set value')))
1258
- return cloneDescriptorProperties(source, result, cloneChild)
1259
- }
1260
-
1261
- if (source instanceof Date) {
1262
- const result = new Date(source.getTime())
1263
- seen.set(source, result)
1264
- return cloneDescriptorProperties(source, result, cloneChild)
1265
- }
1266
-
1267
- if (source instanceof RegExp) {
1268
- const result = new RegExp(source.source, source.flags)
1269
- result.lastIndex = source.lastIndex
1270
- seen.set(source, result)
1271
- return cloneDescriptorProperties(source, result, cloneChild, key => key === 'lastIndex')
1272
- }
1273
-
1274
- if (source instanceof ArrayBuffer) {
1275
- const result = cloneArrayBuffer(source)
1276
- seen.set(source, result)
1277
- return cloneDescriptorProperties(source, result, cloneChild)
1278
- }
1279
-
1280
- if (typeof SharedArrayBuffer !== 'undefined' && source instanceof SharedArrayBuffer) {
1281
- const result = cloneSharedArrayBuffer(source)
1282
- seen.set(source, result)
1283
- return cloneDescriptorProperties(source, result, cloneChild)
1284
- }
1285
-
1286
- if (source instanceof DataView) {
1287
- const buffer = source.buffer.slice(source.byteOffset, source.byteOffset + source.byteLength)
1288
- const result = new DataView(buffer)
1289
- seen.set(source, result)
1290
- return cloneDescriptorProperties(source, result, cloneChild)
1291
- }
1292
-
1293
- if (isTypedArray(source)) {
1294
- const result = new source.constructor(source)
1295
- seen.set(source, result)
1296
- return cloneDescriptorProperties(source, result, cloneChild, isIntegerKey)
1297
- }
1298
-
1299
- if (typeof URL !== 'undefined' && source instanceof URL) {
1300
- const result = new URL(source.href)
1301
- seen.set(source, result)
1302
- // Browser and jsdom URL objects can store implementation details in
1303
- // own symbol properties. Copying those would couple the clone back
1304
- // to the original, so the URL constructor is the complete clone.
1305
- return result
1306
- }
1307
-
1308
- if (typeof URLSearchParams !== 'undefined' && source instanceof URLSearchParams) {
1309
- const result = new URLSearchParams(source)
1310
- seen.set(source, result)
1311
- return result
1312
- }
1313
-
1314
- if (typeof File !== 'undefined' && source instanceof File) {
1315
- const result = new File([source], source.name, {
1316
- type: source.type,
1317
- lastModified: source.lastModified
1318
- })
1319
- seen.set(source, result)
1320
- return result
1321
- }
1322
-
1323
- if (typeof Blob !== 'undefined' && source instanceof Blob) {
1324
- const result = source.slice(0, source.size, source.type)
1325
- seen.set(source, result)
1326
- return result
1327
- }
1328
-
1329
- if (source instanceof Error) {
1330
- const result = cloneErrorObject(source, cloneChild, path)
1331
- seen.set(source, result)
1332
- return cloneDescriptorProperties(source, result, cloneChild, key => (
1333
- key === 'message' || key === 'cause' || key === 'errors' || key === 'stack'
1334
- ))
1335
- }
1336
-
1337
- if (typeof Node !== 'undefined' && source instanceof Node && typeof source.cloneNode === 'function') {
1338
- const result = source.cloneNode(deep)
1339
- seen.set(source, result)
1340
- return result
1341
- }
1342
-
1343
- cannotClone(source, path)
1344
- }
1345
-
1346
- return cloneValue(value)
1347
- }
1
+ export * from '@muze-labs/simplyflow-state'