teamplay 0.4.0-alpha.96 → 0.4.0-alpha.98

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
 
@@ -29,11 +29,11 @@ import {
29
29
  } from '../dataTree.js'
30
30
  import { on as onCustomEvent, removeListener as removeCustomEventListener } from './eventsCompat.js'
31
31
  import { waitForImperativeQueryReady } from './queryReadiness.js'
32
- import { normalizePattern, onModelEvent, removeModelListener } from './modelEvents.js'
32
+ import { isModelEventsEnabled, normalizePattern, onModelEvent, removeModelListener } from './modelEvents.js'
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,9 @@ 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 (shouldMirrorPrivateRefMutationLocally()) {
1065
+ mirrorRefMutationFromTarget(segments, value)
1066
+ }
1064
1067
  }
1065
1068
 
1066
1069
  function delPrivateCompatSync ($signal, options) {
@@ -1115,12 +1118,16 @@ async function setReplaceOnSignal ($signal, value) {
1115
1118
  }
1116
1119
  if (isPublicCollection(segments[0])) {
1117
1120
  const result = await _setPublicDocReplace(segments, value)
1118
- mirrorRefMutationFromTarget(segments, value)
1121
+ if (shouldMirrorPublicRefMutationLocally(segments)) {
1122
+ mirrorRefMutationFromTarget(segments, value)
1123
+ }
1119
1124
  return result
1120
1125
  }
1121
1126
  if (isPrivateMutationForbidden()) throw Error(ERRORS.publicOnly)
1122
1127
  const result = setReplacePrivateData(getOwningRootId($signal), segments, value)
1123
- mirrorRefMutationFromTarget(segments, value)
1128
+ if (shouldMirrorPrivateRefMutationLocally()) {
1129
+ mirrorRefMutationFromTarget(segments, value)
1130
+ }
1124
1131
  return result
1125
1132
  }
1126
1133
 
@@ -1252,6 +1259,27 @@ function getOwningRootId ($signal) {
1252
1259
  return $root?.[ROOT_ID]
1253
1260
  }
1254
1261
 
1262
+ function ensurePrivateRefSource ($signal, methodName) {
1263
+ const segments = $signal?.[SEGMENTS]
1264
+ const collection = segments?.[0]
1265
+ if (typeof collection === 'string' && /^[_$]/.test(collection)) return
1266
+ throw Error(`${methodName} source path must be in a private collection`)
1267
+ }
1268
+
1269
+ function shouldMirrorPublicRefMutationLocally (segments) {
1270
+ if (isSilentContextActive()) return true
1271
+ if (!Array.isArray(segments) || segments.length < 2) return true
1272
+ // Public doc ops emit compat model events only when there is an initialized
1273
+ // Doc runtime (subscribed/fetched). Without runtime we must mirror immediately.
1274
+ const transportHash = JSON.stringify([segments[0], segments[1]])
1275
+ return !docSubscriptions.hasRuntime(transportHash)
1276
+ }
1277
+
1278
+ function shouldMirrorPrivateRefMutationLocally () {
1279
+ if (isSilentContextActive()) return true
1280
+ return !isModelEventsEnabled()
1281
+ }
1282
+
1255
1283
  function shallowCopy (value) {
1256
1284
  const rawValue = raw(value)
1257
1285
  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/orm/dataTree.js CHANGED
@@ -326,6 +326,13 @@ export async function setPublicDocReplace (segments, value) {
326
326
  }
327
327
 
328
328
  const relativePath = segments.slice(2)
329
+ // json0 direct replace ops require every ancestor container to already exist.
330
+ // Racer-like compat set, however, materializes missing/primitive parents while
331
+ // descending into the path. Fall back to the older diff-based path when the
332
+ // direct op would target a non-existent/non-object ancestor.
333
+ if (!canApplyDirectReplaceOp(docState.snapshot || {}, relativePath)) {
334
+ return setPublicDoc(segments, value)
335
+ }
329
336
  const previous = getRaw(segments)
330
337
  const normalizedPrevious = normalizeUndefined(
331
338
  relativePath.length === 0 ? stripIdFields(previous, idFields) : previous
@@ -444,6 +451,16 @@ function normalizeUndefined (value) {
444
451
  return value === undefined ? null : value
445
452
  }
446
453
 
454
+ function canApplyDirectReplaceOp (docSnapshot, relativePath) {
455
+ if (relativePath.length === 0) return true
456
+ let node = docSnapshot
457
+ for (let i = 0; i < relativePath.length - 1; i++) {
458
+ if (node == null || typeof node !== 'object') return false
459
+ node = node[relativePath[i]]
460
+ }
461
+ return node != null && typeof node === 'object'
462
+ }
463
+
447
464
  function normalizeValueForOp (value) {
448
465
  let result = raw(value)
449
466
  if (result != null && typeof result === 'object') result = JSON.parse(JSON.stringify(result))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teamplay",
3
- "version": "0.4.0-alpha.96",
3
+ "version": "0.4.0-alpha.98",
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": "78f33dc002740b425f3a056fad5b7e780a34219c"
86
+ "gitHead": "ae4e0e6ee39e395fa9eb48a2099cc1d5b2a4630d"
87
87
  }
package/react/useSub.js CHANGED
@@ -44,8 +44,8 @@ export function useSubDeferred (signal, params, { async = false, defer, batch =
44
44
  // 1. if it's a promise, throw it so that Suspense can catch it and wait for subscription to finish
45
45
  if (promiseOrSignal.then) {
46
46
  const promise = maybeThrottle(promiseOrSignal)
47
+ const hasPreviousSignal = !!$signalRef.current
47
48
  if (batch) {
48
- const hasPreviousSignal = !!$signalRef.current
49
49
  // Batch suspense must block only on initial load.
50
50
  // On resubscribe we keep rendering previous signal and refresh in background.
51
51
  if (!hasPreviousSignal) {
@@ -61,6 +61,11 @@ export function useSubDeferred (signal, params, { async = false, defer, batch =
61
61
  scheduleUpdate(promise)
62
62
  return
63
63
  }
64
+ // Keep previous snapshot during update re-subscribe and refresh in background.
65
+ if (hasPreviousSignal) {
66
+ scheduleUpdate(promise)
67
+ return $signalRef.current
68
+ }
64
69
  if (compatAttemptCleanup) registerCompatAttemptCleanup(signal, params)
65
70
  throw promise
66
71
  // 2. if it's a signal, we save it into ref to make sure it's not garbage collected while component exists