teamplay 0.4.0-alpha.30 → 0.4.0-alpha.32

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.
@@ -116,6 +116,8 @@ $.users.user1.scope('users', 'user2')
116
116
  Creates a lightweight alias between signals (minimal Racer-style ref).
117
117
  Mutations on the alias are forwarded to the target. The alias mirrors target updates.
118
118
  Reads (`get`/`peek`) are forwarded to the target while the ref is active.
119
+ Ref mirroring is scheduled through Teamplay runtime scheduler, so updates remain batch-friendly
120
+ and do not leak intermediate ref states during a single batched cycle.
119
121
 
120
122
  ```js
121
123
  const $local = $.local.value
@@ -199,7 +201,8 @@ $root.start('_virtual.lesson', $.lessons[lessonId], '_session.userId', (lesson,
199
201
 
200
202
  Behavior:
201
203
  - Calling `start()` again for the same `targetPath` replaces previous reaction.
202
- - `undefined` / `null` result clears target path via normal `set` semantics.
204
+ - `undefined` result applies compat delete semantics at target path.
205
+ - `null` result is stored as `null`.
203
206
  - Returns target signal.
204
207
  - If any dependency temporarily suspends (throws a Promise/thenable), compat skips the whole tick (getter is not called and target is not written).
205
208
  - If `getter` throws a Promise, compat skips that tick and retries on next reactive update.
@@ -365,6 +368,7 @@ Compatibility mode intentionally aligns mutators with Racer. This differs from c
365
368
  | `setDiff` | N/A as compat shim. | Alias to compat `set` for both signatures: `setDiff(value)` and `setDiff(path, value)`. |
366
369
 
367
370
  Migration note: compat behavior is intentionally Racer-aligned and may differ from core mutators.
371
+ Composite compat mutators (`setEach`, `setDiffDeep`) apply updates atomically for Teamplay-scheduled observers via the runtime batch scheduler.
368
372
 
369
373
  ### set(value) and set(path, value)
370
374
 
@@ -40,6 +40,7 @@ import { on as onCustomEvent, removeListener as removeCustomEventListener } from
40
40
  import { normalizePattern, onModelEvent, removeModelListener } from './modelEvents.js'
41
41
  import { setRefLink, removeRefLink } from './refRegistry.js'
42
42
  import { REF_TARGET, resolveRefSignalSafe } from './refFallback.js'
43
+ import { runInBatch, scheduleReaction } from '../batchScheduler.js'
43
44
 
44
45
  class SignalCompat extends Signal {
45
46
  static ID_FIELDS = ['_id', 'id']
@@ -225,7 +226,7 @@ class SignalCompat extends Signal {
225
226
  value = path
226
227
  }
227
228
  const $target = resolveSignal(this, segments)
228
- return setDiffDeepOnSignal($target, value)
229
+ return runInBatch(() => setDiffDeepOnSignal($target, value))
229
230
  }
230
231
 
231
232
  async setDiff (path, value) {
@@ -253,11 +254,13 @@ class SignalCompat extends Signal {
253
254
  if (typeof object !== 'object') {
254
255
  throw Error('Signal.setEach() expects an object argument, got: ' + typeof object)
255
256
  }
256
- const promises = []
257
- for (const key of Object.keys(object)) {
258
- promises.push(SignalCompat.prototype.set.call($target[key], object[key]))
259
- }
260
- await Promise.all(promises)
257
+ return runInBatch(async () => {
258
+ const promises = []
259
+ for (const key of Object.keys(object)) {
260
+ promises.push(SignalCompat.prototype.set.call($target[key], object[key]))
261
+ }
262
+ await Promise.all(promises)
263
+ })
261
264
  }
262
265
 
263
266
  async del (path) {
@@ -574,6 +577,7 @@ class SignalCompat extends Signal {
574
577
  }
575
578
 
576
579
  const REFS = Symbol('compat refs')
580
+ const SKIP_REF_TICK = Symbol('compat ref skip tick')
577
581
 
578
582
  function getRefStore ($signal) {
579
583
  const $root = getRoot($signal) || $signal
@@ -583,15 +587,25 @@ function getRefStore ($signal) {
583
587
 
584
588
  function createRefLink ($from, $to) {
585
589
  const toReaction = observe(() => {
586
- const value = $to.get()
590
+ const value = readRefValue($to)
591
+ if (value === SKIP_REF_TICK) return
587
592
  trackDeep(value)
588
593
  setDiffDeepBypassRef($from, deepCopy(value))
589
- })
594
+ }, { scheduler: scheduleReaction })
590
595
  return () => {
591
596
  unobserve(toReaction)
592
597
  }
593
598
  }
594
599
 
600
+ function readRefValue ($signal) {
601
+ try {
602
+ return $signal.get()
603
+ } catch (err) {
604
+ if (isThenable(err)) return SKIP_REF_TICK
605
+ throw err
606
+ }
607
+ }
608
+
595
609
  function trackDeep (value, seen = new Set()) {
596
610
  if (!value || typeof value !== 'object') return
597
611
  if (seen.has(value)) return
@@ -629,6 +643,10 @@ function isReactLike (value) {
629
643
  return !!(value && typeof value === 'object' && typeof value.$$typeof === 'symbol')
630
644
  }
631
645
 
646
+ function isThenable (value) {
647
+ return !!value && typeof value.then === 'function'
648
+ }
649
+
632
650
  function resolveRefTarget ($signal, target, methodName) {
633
651
  if (isSignalLike(target)) return target
634
652
  if (typeof target === 'string') {
@@ -1,5 +1,6 @@
1
1
  import { observe, unobserve } from '@nx-js/observer-util'
2
2
  import { getRoot } from '../Root.js'
3
+ import { scheduleReaction } from '../batchScheduler.js'
3
4
 
4
5
  const START_REACTIONS = Symbol('compat start reactions')
5
6
  const SKIP_TICK = Symbol('compat start skip tick')
@@ -39,7 +40,7 @@ export function compatStartOnRoot ($root, targetPath, ...depsAndGetter) {
39
40
  }
40
41
  const maybePromise = $target.set(nextValue)
41
42
  if (maybePromise?.catch) maybePromise.catch(ignorePromiseRejection)
42
- })
43
+ }, { scheduler: scheduleReaction })
43
44
  store.set(targetKey, { stop: () => unobserve(reaction) })
44
45
  return $target
45
46
  }
package/orm/Reaction.js CHANGED
@@ -3,6 +3,7 @@ import { SEGMENTS } from './Signal.js'
3
3
  import { set as _set, del as _del } from './dataTree.js'
4
4
  import { LOCAL } from './Value.js'
5
5
  import FinalizationRegistry from '../utils/MockFinalizationRegistry.js'
6
+ import { scheduleReaction } from './batchScheduler.js'
6
7
 
7
8
  // this is `let` to be able to directly change it if needed in tests or in the app
8
9
  export let DELETION_DELAY = 0 // eslint-disable-line prefer-const
@@ -18,7 +19,7 @@ class ReactionSubscriptions {
18
19
  if (this.initialized.has(id)) return
19
20
 
20
21
  this.initialized.set(id, true)
21
- const reactionScheduler = reaction => runReaction(id, reaction)
22
+ const reactionScheduler = reaction => scheduleReaction(() => runReaction(id, reaction))
22
23
  const reaction = observe(fn, { lazy: true, scheduler: reactionScheduler })
23
24
  this.fr.register($value, [id, reaction])
24
25
  runReaction(id, reaction)
package/orm/SignalBase.js CHANGED
@@ -49,6 +49,7 @@ import { DEFAULT_ID_FIELDS, getIdFieldsForSegments, isIdFieldPath, normalizeIdFi
49
49
  import { isCompatEnv } from './compatEnv.js'
50
50
  import { resolveRefSegmentsSafe, resolveRefSignalSafe } from './Compat/refFallback.js'
51
51
  import { compatStartOnRoot, compatStopOnRoot, joinScopePath } from './Compat/startStopCompat.js'
52
+ import { runInBatch } from './batchScheduler.js'
52
53
 
53
54
  export const SEGMENTS = Symbol('path segments targeting the particular node in the data tree')
54
55
  export const ARRAY_METHOD = Symbol('run array method on the signal')
@@ -105,7 +106,7 @@ export class Signal extends Function {
105
106
  if (arguments.length > 1) throw Error('Signal.batch() expects a single argument')
106
107
  if (fn == null) return
107
108
  if (typeof fn !== 'function') throw Error('Signal.batch() expects a function argument')
108
- return fn()
109
+ return runInBatch(fn)
109
110
  }
110
111
 
111
112
  [GET] (method) {
@@ -0,0 +1,62 @@
1
+ let batchDepth = 0
2
+ let isFlushing = false
3
+ const queuedReactions = new Set()
4
+
5
+ export function beginBatch () {
6
+ batchDepth += 1
7
+ }
8
+
9
+ export function endBatch () {
10
+ if (batchDepth === 0) return
11
+ batchDepth -= 1
12
+ if (batchDepth === 0) flushReactions()
13
+ }
14
+
15
+ export function inBatch () {
16
+ return batchDepth > 0
17
+ }
18
+
19
+ export function runInBatch (fn) {
20
+ beginBatch()
21
+ let result
22
+ try {
23
+ result = fn()
24
+ } catch (err) {
25
+ endBatch()
26
+ throw err
27
+ }
28
+ if (result?.then) {
29
+ return Promise.resolve(result).finally(endBatch)
30
+ }
31
+ endBatch()
32
+ return result
33
+ }
34
+
35
+ export function scheduleReaction (reactionFn) {
36
+ if (typeof reactionFn !== 'function') return
37
+ if (inBatch() || isFlushing) {
38
+ queuedReactions.add(reactionFn)
39
+ return
40
+ }
41
+ reactionFn()
42
+ }
43
+
44
+ export function flushReactions () {
45
+ if (isFlushing) return
46
+ isFlushing = true
47
+ try {
48
+ while (queuedReactions.size > 0) {
49
+ const queue = Array.from(queuedReactions)
50
+ queuedReactions.clear()
51
+ for (const reactionFn of queue) reactionFn()
52
+ }
53
+ } finally {
54
+ isFlushing = false
55
+ }
56
+ }
57
+
58
+ export function __resetBatchSchedulerForTests () {
59
+ batchDepth = 0
60
+ isFlushing = false
61
+ queuedReactions.clear()
62
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teamplay",
3
- "version": "0.4.0-alpha.30",
3
+ "version": "0.4.0-alpha.32",
4
4
  "description": "Full-stack signals ORM with multiplayer",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -81,5 +81,5 @@
81
81
  ]
82
82
  },
83
83
  "license": "MIT",
84
- "gitHead": "dc30b21a5d5d2cf5dc0e9ad96ff61fc7fcd8930c"
84
+ "gitHead": "384c14383a45af21bad2f752011db1462f7d8628"
85
85
  }
@@ -6,6 +6,7 @@ import { __increment, __decrement } from '@teamplay/debug'
6
6
  import executionContextTracker from './executionContextTracker.js'
7
7
  import { pipeComponentMeta, useUnmount, useId, useTriggerUpdate } from './helpers.js'
8
8
  import trapRender from './trapRender.js'
9
+ import { scheduleReaction } from '../orm/batchScheduler.js'
9
10
 
10
11
  const DEFAULT_THROTTLE_TIMEOUT = 100
11
12
 
@@ -52,7 +53,7 @@ export default function convertToObserver (BaseComponent, {
52
53
  componentId
53
54
  })
54
55
  reactionRef.current = observe(trappedRender, {
55
- scheduler: update,
56
+ scheduler: () => scheduleReaction(update),
56
57
  lazy: true
57
58
  })
58
59
  }