@blender-ui/native 0.0.0-stage → 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/signals.js ADDED
@@ -0,0 +1,628 @@
1
+ // Signals: BUI's hooks for JavaScript (the Rust crate's Signal / Memo /
2
+ // Effect, bindings/rust/blenderui/src/hooks.rs).
3
+ //
4
+ // const count = signal(0)
5
+ // const doubled = memo(() => count.value * 2) // recomputed when count changes
6
+ // effect(() => console.log(doubled.value)) // re-runs when doubled changes
7
+ // label.textContent = doubled // bound: only this text node updates
8
+ // count.value++
9
+ //
10
+ // Bound to an element (`el.setAttribute('class', sig)`, `el.style = sig`,
11
+ // `text.data = sig`, or a signal as a React prop or child), a write updates
12
+ // that one node: no component re-renders, no tree rebuilt, almost no garbage.
13
+ //
14
+ // Push-pull, glitch-free: a write marks direct readers dirty and indirect
15
+ // ones "check"; memos recompute lazily and stop propagation when their value
16
+ // is unchanged; effects run once per batch, after their sources settle. A
17
+ // memo nobody observes holds no subscriptions (it is garbage once dropped)
18
+ // and revalidates on read.
19
+ //
20
+ // Transitions (`startTransition`) mark the effects their writes trigger as
21
+ // low priority: they run after urgent work, in 5 ms slices.
22
+ //
23
+ // SPDX-License-Identifier: GPL-2.0-or-later
24
+ 'use strict'
25
+
26
+ const CLEAN = 0, CHECK = 1, DIRTY = 2
27
+ const NONE = 0, URGENT = 1, DEFERRED = 2
28
+
29
+ let observer = null // the memo / effect running now (collects what it reads)
30
+ let owner = null // the effect running now (owns effects created inside it)
31
+ let batchDepth = 0
32
+ let flushing = false
33
+ let writes = 0 // bumped by every signal write: unobserved memos revalidate against it
34
+ const pending = []
35
+
36
+ let transition = null // the transition whose writes are being made now
37
+ const deferred = [] // effects and tasks of transitions, run in slices
38
+ let deferredAt = 0
39
+ let deferredScheduled = false
40
+ const waiting = new Set() // transitions waiting for the deferred queue
41
+
42
+ function track (source) {
43
+ if (observer !== null && !observer._deps.has(source)) {
44
+ observer._deps.add(source)
45
+ if (observer._attached) source._addSub(observer)
46
+ }
47
+ }
48
+
49
+ /** A value that re-runs what reads it when it changes (Rust's `Signal`). */
50
+ class Signal {
51
+ constructor (value, options) {
52
+ this._value = value
53
+ this._subs = new Set()
54
+ this._equals = options?.equals ?? Object.is
55
+ }
56
+
57
+ /** Read (and subscribe the running memo / effect). */
58
+ get value () { track(this); return this._value }
59
+ set value (v) { this.set(v) }
60
+ get () { return this.value }
61
+ /** Read without subscribing. */
62
+ peek () { return this._value }
63
+
64
+ set (v) {
65
+ if (this._equals(this._value, v)) return
66
+ this._value = v
67
+ writes++
68
+ for (const s of this._subs) s._mark(DIRTY)
69
+ flush()
70
+ }
71
+
72
+ /** `count.update(n => n + 1)` */
73
+ update (fn) { this.set(fn(this._value)) }
74
+ /** Rust's `with`: `fn(value)`, subscribing. */
75
+ with (fn) { return fn(this.value) }
76
+ /** Call `fn(value)` now and on every change; returns the unsubscribe function. */
77
+ subscribe (fn) { return effect(() => { fn(this.value) }) }
78
+ toString () { return String(this.value) }
79
+ /** `count + 1`, `count > 5` read the value (`===` and truthiness don't: use `.value`). */
80
+ valueOf () { return this.value }
81
+ toJSON () { return this.value }
82
+
83
+ _addSub (s) { this._subs.add(s) }
84
+ _removeSub (s) { this._subs.delete(s) }
85
+ }
86
+
87
+ class Computation {
88
+ constructor (fn) {
89
+ this._fn = fn
90
+ this._deps = new Set()
91
+ this._state = DIRTY
92
+ this._attached = false
93
+ }
94
+
95
+ _unsubscribe () {
96
+ if (this._attached) for (const d of this._deps) d._removeSub(this)
97
+ this._deps.clear()
98
+ }
99
+
100
+ _track () {
101
+ this._unsubscribe()
102
+ const prev = observer
103
+ observer = this
104
+ try {
105
+ return this._fn()
106
+ } finally {
107
+ observer = prev
108
+ }
109
+ }
110
+
111
+ /** Bring up to date: re-run only if a source really changed. */
112
+ _refresh () {
113
+ if (this._state === CHECK) {
114
+ for (const d of this._deps) {
115
+ if (d instanceof Memo) {
116
+ d._refresh()
117
+ if (this._state === DIRTY) break
118
+ }
119
+ }
120
+ }
121
+ if (this._state === DIRTY) {
122
+ this._state = CLEAN // a mark made while running (a write to a source) re-queues it
123
+ this._run()
124
+ } else {
125
+ this._state = CLEAN
126
+ }
127
+ }
128
+ }
129
+
130
+ /** A derived value, recomputed only when what it read changed (Rust's `Memo`). */
131
+ class Memo extends Computation {
132
+ constructor (fn, options) {
133
+ super(fn)
134
+ this._subs = new Set()
135
+ this._value = undefined
136
+ this._init = false
137
+ this._seen = -1 // `writes` when last known up to date while detached
138
+ this._error = null // { error } when the computation threw: rethrown on read
139
+ this._lowPriority = false // marked by a transition only
140
+ this._equals = options?.equals ?? Object.is
141
+ }
142
+
143
+ get value () {
144
+ track(this)
145
+ this._refresh()
146
+ if (this._error !== null) throw this._error.error
147
+ return this._value
148
+ }
149
+
150
+ get () { return this.value }
151
+ peek () {
152
+ this._refresh()
153
+ if (this._error !== null) throw this._error.error
154
+ return this._value
155
+ }
156
+ with (fn) { return fn(this.value) }
157
+ subscribe (fn) { return effect(() => { fn(this.value) }) }
158
+ toString () { return String(this.value) }
159
+ /** `count + 1`, `count > 5` read the value (`===` and truthiness don't: use `.value`). */
160
+ valueOf () { return this.value }
161
+ toJSON () { return this.value }
162
+
163
+ _refresh () {
164
+ if (this._attached) return super._refresh()
165
+ // nobody observes this memo: revalidate against the global write count
166
+ const at = writes
167
+ this._state = CLEAN
168
+ if (this._seen !== at || !this._init) this._run()
169
+ this._seen = at
170
+ }
171
+
172
+ _run () {
173
+ let v
174
+ try {
175
+ v = this._track()
176
+ } catch (error) {
177
+ this._init = true
178
+ this._error = { error }
179
+ for (const s of this._subs) if (s._state === CHECK) s._state = DIRTY // readers waiting on this check
180
+ return
181
+ }
182
+ if (!this._init || this._error !== null || !this._equals(this._value, v)) {
183
+ this._init = true
184
+ this._error = null
185
+ this._value = v
186
+ for (const s of this._subs) if (s._state === CHECK) s._state = DIRTY // readers waiting on this check
187
+ }
188
+ }
189
+
190
+ _mark (state) {
191
+ const urgent = transition === null
192
+ const was = this._state
193
+ // already marked: pass it on only to make a transition's mark urgent
194
+ if (was >= state && !(urgent && this._lowPriority)) return
195
+ if (state > was) this._state = state
196
+ const propagate = was === CLEAN || (urgent && this._lowPriority)
197
+ this._lowPriority = !urgent && (was === CLEAN || this._lowPriority)
198
+ if (propagate) for (const s of this._subs) s._mark(CHECK)
199
+ }
200
+
201
+ _addSub (s) {
202
+ this._subs.add(s)
203
+ if (!this._attached) {
204
+ // first observer: subscribe to the sources (stale if anything was written since)
205
+ this._attached = true
206
+ if (this._seen !== writes) this._state = DIRTY
207
+ for (const d of this._deps) d._addSub(this)
208
+ }
209
+ }
210
+
211
+ _removeSub (s) {
212
+ this._subs.delete(s)
213
+ if (this._subs.size === 0 && this._attached) {
214
+ // last observer gone: let go of the sources, so this memo can be collected
215
+ for (const d of this._deps) d._removeSub(this)
216
+ this._attached = false
217
+ this._seen = this._state === CLEAN ? writes : -1
218
+ }
219
+ }
220
+ }
221
+
222
+ /** Runs `fn` now and again when what it read changes (Rust's `Effect`). */
223
+ class Effect extends Computation {
224
+ constructor (fn) {
225
+ super(fn)
226
+ this._attached = true
227
+ this._cleanup = undefined
228
+ this._children = null
229
+ this._disposed = false
230
+ this._queued = NONE
231
+ if (owner !== null) (owner._children ??= []).push(this)
232
+ this._state = CLEAN
233
+ batchDepth++ // writes made by this run re-run it after, not inside, the run
234
+ try {
235
+ this._run()
236
+ } catch (error) {
237
+ this.dispose() // no half-subscribed effect left behind
238
+ throw error
239
+ } finally {
240
+ batchDepth--
241
+ flush()
242
+ }
243
+ }
244
+
245
+ _run () {
246
+ this._clean()
247
+ const prevOwner = owner
248
+ owner = this
249
+ try {
250
+ const r = this._track()
251
+ if (typeof r === 'function') this._cleanup = r
252
+ } finally {
253
+ owner = prevOwner
254
+ }
255
+ }
256
+
257
+ _clean () {
258
+ if (this._children !== null) {
259
+ for (const c of this._children) c.dispose()
260
+ this._children = null
261
+ }
262
+ if (this._cleanup !== undefined) {
263
+ const c = this._cleanup
264
+ this._cleanup = undefined
265
+ untrack(c)
266
+ }
267
+ }
268
+
269
+ _mark (state) {
270
+ if (state > this._state) this._state = state
271
+ if (transition === null) {
272
+ if (this._queued !== URGENT) {
273
+ this._queued = URGENT
274
+ pending.push(this)
275
+ }
276
+ } else if (this._queued === NONE) {
277
+ this._queued = DEFERRED
278
+ defer(this)
279
+ } else if (this._queued === DEFERRED) {
280
+ holdTransition() // this transition also waits for the deferred run
281
+ }
282
+ }
283
+
284
+ dispose () {
285
+ if (this._disposed) return
286
+ this._disposed = true
287
+ this._unsubscribe()
288
+ this._attached = false // reads after dispose (mid-run) don't subscribe
289
+ this._clean()
290
+ }
291
+ }
292
+
293
+ function runEffect (e) {
294
+ e._queued = NONE
295
+ if (e._disposed) return
296
+ try {
297
+ e._refresh()
298
+ } catch (err) {
299
+ e._state = CLEAN
300
+ reportError(err)
301
+ }
302
+ }
303
+
304
+ function flush () {
305
+ if (batchDepth > 0 || flushing) return
306
+ flushing = true
307
+ let i = 0
308
+ try {
309
+ for (; i < pending.length; i++) {
310
+ if (i > 100000) throw new Error('blenderui: effects keep re-triggering each other')
311
+ if (pending[i]._queued === URGENT) runEffect(pending[i])
312
+ }
313
+ } finally {
314
+ for (; i < pending.length; i++) if (pending[i]._queued === URGENT) pending[i]._queued = NONE
315
+ pending.length = 0
316
+ flushing = false
317
+ }
318
+ }
319
+
320
+ function reportError (err) {
321
+ // like a browser: one effect's error doesn't stop the others
322
+ queueMicrotask(() => { throw err })
323
+ }
324
+
325
+ // ---- transitions -----------------------------------------------------------
326
+
327
+ class Transition {
328
+ constructor () {
329
+ this._open = 1 // the synchronous part
330
+ this._done = []
331
+ this._queued = false
332
+ }
333
+
334
+ _hold () { this._open++ }
335
+ _release () {
336
+ if (--this._open === 0) for (const f of this._done.splice(0)) f()
337
+ }
338
+ }
339
+
340
+ const now = typeof performance !== 'undefined' ? () => performance.now() : () => Date.now()
341
+ const macrotask = typeof setImmediate === 'function' ? setImmediate : (f) => setTimeout(f, 0)
342
+
343
+ /** The current transition waits for the deferred queue to drain. */
344
+ function holdTransition () {
345
+ if (transition !== null && !transition._queued) {
346
+ transition._queued = true
347
+ transition._hold()
348
+ waiting.add(transition)
349
+ }
350
+ }
351
+
352
+ /** Queue an effect or task of the current transition. */
353
+ function defer (item) {
354
+ deferred.push(item)
355
+ holdTransition()
356
+ if (!deferredScheduled) {
357
+ deferredScheduled = true
358
+ macrotask(runDeferred)
359
+ }
360
+ }
361
+
362
+ function runDeferred () {
363
+ deferredScheduled = false
364
+ const end = now() + 5
365
+ while (deferredAt < deferred.length) {
366
+ const item = deferred[deferredAt++]
367
+ const t = item._transition
368
+ const prev = transition
369
+ transition = t ?? IDLE
370
+ batchDepth++
371
+ try {
372
+ if (typeof item === 'function') {
373
+ try { item() } catch (err) { reportError(err) }
374
+ } else if (item._queued === DEFERRED) {
375
+ runEffect(item)
376
+ }
377
+ } finally {
378
+ batchDepth--
379
+ transition = prev
380
+ }
381
+ flush()
382
+ if (now() >= end && deferredAt < deferred.length) {
383
+ // yield to urgent work (input, frames), then carry on
384
+ deferredScheduled = true
385
+ macrotask(runDeferred)
386
+ return
387
+ }
388
+ }
389
+ deferred.length = 0
390
+ deferredAt = 0
391
+ const done = [...waiting]
392
+ waiting.clear()
393
+ for (const t of done) {
394
+ t._queued = false
395
+ t._release()
396
+ }
397
+ }
398
+
399
+ const IDLE = new Transition()
400
+
401
+ /**
402
+ * Run `fn` as a transition: what its signal writes trigger (bindings,
403
+ * effects) runs after urgent work, in slices. Like React, only writes made
404
+ * before `fn`'s first `await` belong to it. Returns a promise that settles
405
+ * when `fn` (and its promise, if any) and the deferred work are done; it
406
+ * rejects with the error of an async `fn` (a synchronous throw propagates).
407
+ */
408
+ function startTransition (fn) {
409
+ const t = new Transition()
410
+ let failure = null
411
+ const done = new Promise((resolve, reject) => {
412
+ t._done.push(() => (failure ? reject(failure.error) : resolve()))
413
+ })
414
+ const prev = transition
415
+ transition = t
416
+ batchDepth++
417
+ let result
418
+ try {
419
+ result = fn()
420
+ } catch (error) {
421
+ t._release()
422
+ throw error
423
+ } finally {
424
+ batchDepth--
425
+ transition = prev
426
+ flush()
427
+ }
428
+ if (result && typeof result.then === 'function') {
429
+ t._hold()
430
+ result.then(() => t._release(), (error) => { failure = { error }; t._release() })
431
+ }
432
+ t._release()
433
+ return done
434
+ }
435
+
436
+ /** Inside a transition: call `cb` when it completes. False outside one. */
437
+ function onTransitionDone (cb) {
438
+ if (transition === null || transition === IDLE) return false
439
+ transition._done.push(cb) // the transition is open while its body runs
440
+ return true
441
+ }
442
+
443
+ /** Run `fn` at transition priority, later (deferred values). */
444
+ function scheduleDeferred (fn) {
445
+ fn._transition = IDLE
446
+ const prev = transition
447
+ transition = IDLE
448
+ try {
449
+ defer(fn)
450
+ } finally {
451
+ transition = prev
452
+ }
453
+ }
454
+
455
+ /** Run `fn` with urgent priority, even inside a transition. */
456
+ function urgent (fn) {
457
+ const prev = transition
458
+ transition = null
459
+ try {
460
+ return fn()
461
+ } finally {
462
+ transition = prev
463
+ }
464
+ }
465
+
466
+ const inTransition = () => transition !== null
467
+
468
+ // ---- render tracking (components) ------------------------------------------
469
+
470
+ /**
471
+ * What a component's render read, and a callback when any of it changes.
472
+ * `run(fn)` collects reads without subscribing (a render may be thrown
473
+ * away); `commit(reads)` subscribes once the render is committed; `pause`
474
+ * / `resume` follow mount and unmount. Built for React components.
475
+ */
476
+ class Tracker extends Computation {
477
+ constructor (onChange) {
478
+ super(null)
479
+ this._attached = true
480
+ this._state = CLEAN
481
+ this._queued = NONE
482
+ this._disposed = false
483
+ this._onChange = onChange
484
+ this._pausedAt = 0
485
+ }
486
+
487
+ /** Run `fn`, collecting the signals it reads. Returns `[result, reads]`. */
488
+ run (fn) {
489
+ const reads = { _deps: new Set(), _attached: false, _render: true, _at: 0 }
490
+ const prev = observer
491
+ observer = reads
492
+ try {
493
+ const result = fn()
494
+ reads._at = writes // writes made by the render itself don't count
495
+ return [result, reads]
496
+ } finally {
497
+ observer = prev
498
+ }
499
+ }
500
+
501
+ /** The render that read `reads` was committed: follow those signals now. */
502
+ commit (reads) {
503
+ if (reads === null) return
504
+ const next = reads._deps
505
+ if (this._attached) {
506
+ for (const d of this._deps) if (!next.has(d)) d._removeSub(this)
507
+ for (const d of next) if (!this._deps.has(d)) d._addSub(this)
508
+ }
509
+ this._deps = next
510
+ this._state = CLEAN
511
+ if (writes !== reads._at && next.size > 0) this._onChange() // changed in between
512
+ }
513
+
514
+ pause () {
515
+ if (!this._attached) return
516
+ for (const d of this._deps) d._removeSub(this)
517
+ this._attached = false
518
+ this._pausedAt = writes
519
+ }
520
+
521
+ resume () {
522
+ if (this._attached) return
523
+ this._attached = true
524
+ for (const d of this._deps) d._addSub(this)
525
+ if (writes !== this._pausedAt && this._deps.size > 0) this._onChange()
526
+ }
527
+
528
+ _run () { this._onChange() }
529
+ dispose () { this.pause(); this._disposed = true }
530
+ }
531
+ Tracker.prototype._mark = Effect.prototype._mark
532
+
533
+ const inRender = () => observer !== null && observer._render === true
534
+
535
+ /** A ref whose `.current` is a signal for effects and memos, but not for
536
+ * renders (reading a ref while rendering doesn't subscribe, as in React). */
537
+ class Ref extends Signal {
538
+ constructor (value) {
539
+ super(value)
540
+ // an own property: React (dev) rejects ref objects without one
541
+ Object.defineProperty(this, 'current', {
542
+ get () { return inRender() ? this._value : this.value },
543
+ set (v) { this.set(v) },
544
+ enumerable: true,
545
+ })
546
+ }
547
+ }
548
+
549
+ // ---- API -------------------------------------------------------------------
550
+
551
+ /** `signal(0)`: a read-write signal (`.value`, `.set`, `.update`). */
552
+ function signal (value, options) { return new Signal(value, options) }
553
+
554
+ /** `memo(() => a.value + b.value)`: a lazily derived, cached signal. */
555
+ function memo (fn, options) { return new Memo(fn, options) }
556
+
557
+ /** `effect(() => ...)`: runs now and on changes; `fn` may return a cleanup. Returns `dispose`. */
558
+ function effect (fn) {
559
+ const e = new Effect(fn)
560
+ return () => e.dispose()
561
+ }
562
+
563
+ /** Run `fn`, deferring effects until it returns (several writes, one update). */
564
+ function batch (fn) {
565
+ batchDepth++
566
+ try {
567
+ return fn()
568
+ } finally {
569
+ batchDepth--
570
+ flush()
571
+ }
572
+ }
573
+
574
+ /** Run `fn` outside any owning effect: effects it creates live until disposed. */
575
+ function unowned (fn) {
576
+ const prev = owner
577
+ owner = null
578
+ try {
579
+ return fn()
580
+ } finally {
581
+ owner = prev
582
+ }
583
+ }
584
+
585
+ /** Run `fn` without subscribing to what it reads. */
586
+ function untrack (fn) {
587
+ const prev = observer
588
+ observer = null
589
+ try {
590
+ return fn()
591
+ } finally {
592
+ observer = prev
593
+ }
594
+ }
595
+
596
+ /** Leptos / Solid style: `const [count, setCount] = createSignal(0)`. */
597
+ function createSignal (value, options) {
598
+ const s = new Signal(value, options)
599
+ return [() => s.value, (v) => s.set(typeof v === 'function' ? v(s._value) : v)]
600
+ }
601
+
602
+ const isSignal = (x) => x instanceof Signal || x instanceof Memo
603
+
604
+ /** A ref (`{ current }`) whose changes effects and memos can follow. */
605
+ function ref (value) { return new Ref(value) }
606
+
607
+ module.exports = {
608
+ Signal,
609
+ Memo,
610
+ Effect,
611
+ signal,
612
+ memo,
613
+ computed: memo,
614
+ effect,
615
+ batch,
616
+ untrack,
617
+ unowned,
618
+ createSignal,
619
+ isSignal,
620
+ startTransition,
621
+ urgent,
622
+ inTransition,
623
+ onTransitionDone,
624
+ scheduleDeferred,
625
+ Tracker,
626
+ Ref,
627
+ ref,
628
+ }