teamplay 0.4.0-alpha.95 → 0.4.0-alpha.97

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.
@@ -119,6 +119,10 @@ Reads (`get`/`peek`) are forwarded to the target while the ref is active.
119
119
  Ref mirroring is scheduled through Teamplay runtime scheduler, so updates remain batch-friendly
120
120
  and do not leak intermediate ref states during a single batched cycle.
121
121
 
122
+ Source path restriction:
123
+ - The ref source path (`$from`) must be in a private collection (`_session`, `_page`, `$local`, etc.).
124
+ - Public source paths are not supported.
125
+
122
126
  ```js
123
127
  const $local = $.local.value
124
128
  const $user = $.users.user1
@@ -201,7 +205,6 @@ $table.dataSource.get()
201
205
 
202
206
  **Limitations vs Racer**
203
207
  - No `refList`, `refMap`.
204
- - No automatic list index patching on insert/remove/move.
205
208
  - No event emissions specific to refs.
206
209
  - No support for racer-style ref meta/options beyond the basic signature.
207
210
 
@@ -33,7 +33,7 @@ import { normalizePattern, onModelEvent, removeModelListener } from './modelEven
33
33
  import { setRefLink, removeRefLink, getAllRefLinks } from './refRegistry.js'
34
34
  import { REF_TARGET, resolveRefSignalSafe, resolveRefSegmentsSafe } from './refFallback.js'
35
35
  import { runInBatch } from '../batchScheduler.js'
36
- import { runInSilentContext, runInModelEventsSilentContext } from './silentContext.js'
36
+ import { runInSilentContext, runInModelEventsSilentContext, isSilentContextActive } from './silentContext.js'
37
37
  import universal$ from '../../react/universal$.js'
38
38
  import { getRootContext } from '../rootContext.js'
39
39
  import disposeRootContext from '../disposeRootContext.js'
@@ -618,6 +618,7 @@ class SignalCompat extends Signal {
618
618
  }
619
619
  if (!$to) throw Error('Signal.ref() expects a target path or signal')
620
620
  if ($from === $to) return $from
621
+ ensurePrivateRefSource($from, 'Signal.ref()')
621
622
  const store = getRefStore($from)
622
623
  const fromPath = $from.path()
623
624
  const existing = store.get(fromPath)
@@ -1060,7 +1061,7 @@ function setReplacePrivateCompatSync ($signal, value) {
1060
1061
  value = normalizeIdFields(value, idFields, segments[1])
1061
1062
  }
1062
1063
  setReplacePrivateData(getOwningRootId($signal), segments, value)
1063
- mirrorRefMutationFromTarget(segments, value)
1064
+ if (isSilentContextActive()) mirrorRefMutationFromTarget(segments, value)
1064
1065
  }
1065
1066
 
1066
1067
  function delPrivateCompatSync ($signal, options) {
@@ -1115,12 +1116,14 @@ async function setReplaceOnSignal ($signal, value) {
1115
1116
  }
1116
1117
  if (isPublicCollection(segments[0])) {
1117
1118
  const result = await _setPublicDocReplace(segments, value)
1118
- mirrorRefMutationFromTarget(segments, value)
1119
+ if (shouldMirrorPublicRefMutationLocally(segments)) {
1120
+ mirrorRefMutationFromTarget(segments, value)
1121
+ }
1119
1122
  return result
1120
1123
  }
1121
1124
  if (isPrivateMutationForbidden()) throw Error(ERRORS.publicOnly)
1122
1125
  const result = setReplacePrivateData(getOwningRootId($signal), segments, value)
1123
- mirrorRefMutationFromTarget(segments, value)
1126
+ if (isSilentContextActive()) mirrorRefMutationFromTarget(segments, value)
1124
1127
  return result
1125
1128
  }
1126
1129
 
@@ -1252,6 +1255,22 @@ function getOwningRootId ($signal) {
1252
1255
  return $root?.[ROOT_ID]
1253
1256
  }
1254
1257
 
1258
+ function ensurePrivateRefSource ($signal, methodName) {
1259
+ const segments = $signal?.[SEGMENTS]
1260
+ const collection = segments?.[0]
1261
+ if (typeof collection === 'string' && /^[_$]/.test(collection)) return
1262
+ throw Error(`${methodName} source path must be in a private collection`)
1263
+ }
1264
+
1265
+ function shouldMirrorPublicRefMutationLocally (segments) {
1266
+ if (isSilentContextActive()) return true
1267
+ if (!Array.isArray(segments) || segments.length < 2) return true
1268
+ // Public doc ops emit compat model events only when there is an initialized
1269
+ // Doc runtime (subscribed/fetched). Without runtime we must mirror immediately.
1270
+ const transportHash = JSON.stringify([segments[0], segments[1]])
1271
+ return !docSubscriptions.hasRuntime(transportHash)
1272
+ }
1273
+
1255
1274
  function shallowCopy (value) {
1256
1275
  const rawValue = raw(value)
1257
1276
  if (Array.isArray(rawValue)) return rawValue.slice()
@@ -1,8 +1,10 @@
1
1
  import { getRefLinks, getRefRootIds } from './refRegistry.js'
2
2
  import { isCompatEnv } from '../compatEnv.js'
3
- import { isSilentContextActive, isModelEventsSilentContextActive } from './silentContext.js'
3
+ import { isSilentContextActive, isModelEventsSilentContextActive, runInModelEventsSilentContext } from './silentContext.js'
4
4
  import { normalizeRootId } from '../rootScope.js'
5
5
  import { getRootContext, getRootContexts } from '../rootContext.js'
6
+ import { setReplace as setReplaceInDataTree, del as delFromDataTree } from '../dataTree.js'
7
+ import { setReplacePrivateData, delPrivateData } from '../privateData.js'
6
8
 
7
9
  const MODEL_EVENT_NAMES = ['change', 'all']
8
10
 
@@ -70,6 +72,8 @@ export function emitModelChange (path, value, prevValue, meta) {
70
72
  if (!isPathPrefix(link.toSegments, segments)) continue
71
73
  if (link.mirrorOnly && typeof link.onChange === 'function') {
72
74
  link.onChange()
75
+ } else if (!link.mirrorOnly) {
76
+ mirrorRefAliasFromTargetSegments(rootId, link, segments, value, meta)
73
77
  }
74
78
  const suffix = segments.slice(link.toSegments.length)
75
79
  const nextSegments = link.fromSegments.concat(suffix)
@@ -107,6 +111,49 @@ function splitPattern (pattern) {
107
111
  return pattern.split('.').filter(Boolean)
108
112
  }
109
113
 
114
+ function mirrorRefAliasFromTargetSegments (rootId, link, targetSegments, value, meta) {
115
+ const suffix = targetSegments.slice(link.toSegments.length)
116
+ const fromSegments = link.fromSegments.concat(suffix)
117
+ const fromRootId = normalizeRootId(link.fromRootId ?? rootId)
118
+ const shouldDelete = shouldDeleteMirroredPath(value, meta)
119
+ runInModelEventsSilentContext(() => {
120
+ if (isPrivateSegments(fromSegments)) {
121
+ if (shouldDelete) {
122
+ delPrivateData(fromRootId, fromSegments)
123
+ } else {
124
+ setReplacePrivateData(fromRootId, fromSegments, cloneValue(value))
125
+ }
126
+ return
127
+ }
128
+ if (shouldDelete) {
129
+ delFromDataTree(fromSegments)
130
+ return
131
+ }
132
+ setReplaceInDataTree(fromSegments, cloneValue(value))
133
+ })
134
+ }
135
+
136
+ function isPrivateSegments (segments) {
137
+ if (!Array.isArray(segments) || !segments.length) return false
138
+ return /^[_$]/.test(String(segments[0]))
139
+ }
140
+
141
+ function shouldDeleteMirroredPath (value, meta) {
142
+ if (meta?.op === 'setReplace') return false
143
+ if (meta?.op === 'del') return true
144
+ return value === undefined
145
+ }
146
+
147
+ function cloneValue (value) {
148
+ if (Array.isArray(value)) return value.map(cloneValue)
149
+ if (value && typeof value === 'object') {
150
+ const cloned = {}
151
+ for (const key of Object.keys(value)) cloned[key] = cloneValue(value[key])
152
+ return cloned
153
+ }
154
+ return value
155
+ }
156
+
110
157
  function getModelEventRootStore (eventName, rootId, create = false) {
111
158
  return getRootContext(normalizeRootId(rootId), create)?.getModelEventStore(eventName, create)
112
159
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teamplay",
3
- "version": "0.4.0-alpha.95",
3
+ "version": "0.4.0-alpha.97",
4
4
  "description": "Full-stack signals ORM with multiplayer",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -83,5 +83,5 @@
83
83
  ]
84
84
  },
85
85
  "license": "MIT",
86
- "gitHead": "43c734951b848a2da50848e9930e50cdf7fdd9d1"
86
+ "gitHead": "633e39dc754332bcd3d95ddc4cfb3522f0bfd694"
87
87
  }
@@ -1,37 +1,46 @@
1
1
  class RenderAttemptDestroyer {
2
2
  constructor () {
3
3
  this.fns = []
4
- this.compatArmed = false
4
+ this.compatAttemptCleanupArmed = false
5
+ this.suspenseGateArmed = false
5
6
  }
6
7
 
7
8
  add (fn, { compat = false } = {}) {
8
9
  if (typeof fn !== 'function') return
9
10
  this.fns.push(fn)
10
- if (compat) this.compatArmed = true
11
+ if (compat) this.compatAttemptCleanupArmed = true
11
12
  }
12
13
 
13
- armCompat () {
14
- this.compatArmed = true
14
+ armCompatAttemptCleanup () {
15
+ this.compatAttemptCleanupArmed = true
15
16
  }
16
17
 
17
- getDestructor () {
18
- if (!this.compatArmed) {
19
- this.reset()
20
- return undefined
21
- }
18
+ armSuspenseGate () {
19
+ this.suspenseGateArmed = true
20
+ }
22
21
 
23
- const fns = [...this.fns]
22
+ consumeThenableHandling () {
23
+ const shouldRunAttemptCleanup = this.compatAttemptCleanupArmed && this.fns.length > 0
24
+ const shouldKeepShellAlive = this.suspenseGateArmed || shouldRunAttemptCleanup
25
+ let destroyAttempt
26
+ if (shouldRunAttemptCleanup) {
27
+ const fns = [...this.fns]
28
+ destroyAttempt = async () => {
29
+ await Promise.allSettled(fns.map(fn => fn()))
30
+ fns.length = 0
31
+ }
32
+ }
24
33
  this.reset()
25
- return async () => {
26
- if (fns.length === 0) return
27
- await Promise.allSettled(fns.map(fn => fn()))
28
- fns.length = 0
34
+ return {
35
+ shouldKeepShellAlive,
36
+ destroyAttempt
29
37
  }
30
38
  }
31
39
 
32
40
  reset () {
33
41
  this.fns.length = 0
34
- this.compatArmed = false
42
+ this.compatAttemptCleanupArmed = false
43
+ this.suspenseGateArmed = false
35
44
  }
36
45
  }
37
46
 
@@ -24,8 +24,11 @@ export default function trapRender ({ render, cache, destroy, componentId }) {
24
24
  destroyed = true
25
25
  throw err
26
26
  }
27
- const destroyAttempt = renderAttemptDestroyer.getDestructor()
28
- if (destroyAttempt) {
27
+ const {
28
+ shouldKeepShellAlive,
29
+ destroyAttempt
30
+ } = renderAttemptDestroyer.consumeThenableHandling()
31
+ if (shouldKeepShellAlive) {
29
32
  throw Promise.resolve(err).then(() => destroyAttempt?.())
30
33
  }
31
34
 
package/react/useSub.js CHANGED
@@ -158,6 +158,7 @@ function maybeThrottle (promise) {
158
158
  function registerCompatAttemptCleanup (signal, params) {
159
159
  // Compat hooks don't build per-hook init objects like Racer.
160
160
  // We still need a marker so trapRender can defer observer-shell cleanup
161
- // to Suspense resolution instead of tearing the whole shell down immediately.
162
- renderAttemptDestroyer.armCompat()
161
+ // only when a real attempt cleanup exists.
162
+ // This path must not arm suspense-gate keep-alive by itself.
163
+ renderAttemptDestroyer.armCompatAttemptCleanup()
163
164
  }
@@ -28,7 +28,7 @@ export default function useSuspendMemo (factory, deps) {
28
28
  if (entry.status === 'done') return entry.value
29
29
  if (entry.status === 'pending') {
30
30
  markCompatComponent(componentId)
31
- renderAttemptDestroyer.armCompat()
31
+ renderAttemptDestroyer.armSuspenseGate()
32
32
  throw entry.promise
33
33
  }
34
34
 
@@ -47,7 +47,7 @@ export default function useSuspendMemo (factory, deps) {
47
47
  entry.status = 'pending'
48
48
  entry.promise = promise
49
49
  markCompatComponent(componentId)
50
- renderAttemptDestroyer.armCompat()
50
+ renderAttemptDestroyer.armSuspenseGate()
51
51
  throw promise
52
52
  }
53
53
  }