teamplay 0.4.0-alpha.97 → 0.4.0-alpha.99

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.
@@ -1,4 +1,5 @@
1
1
  import { raw, observe, unobserve } from '@nx-js/observer-util'
2
+ import arrayDiff from 'arraydiff'
2
3
  import {
3
4
  Signal,
4
5
  GETTERS,
@@ -29,7 +30,7 @@ import {
29
30
  } from '../dataTree.js'
30
31
  import { on as onCustomEvent, removeListener as removeCustomEventListener } from './eventsCompat.js'
31
32
  import { waitForImperativeQueryReady } from './queryReadiness.js'
32
- import { normalizePattern, onModelEvent, removeModelListener } from './modelEvents.js'
33
+ import { isModelEventsEnabled, normalizePattern, onModelEvent, removeModelListener } from './modelEvents.js'
33
34
  import { setRefLink, removeRefLink, getAllRefLinks } from './refRegistry.js'
34
35
  import { REF_TARGET, resolveRefSignalSafe, resolveRefSegmentsSafe } from './refFallback.js'
35
36
  import { runInBatch } from '../batchScheduler.js'
@@ -974,14 +975,14 @@ async function diffDeepCompat ($signal, before, after) {
974
975
  if (before === after) return
975
976
 
976
977
  if (Array.isArray(before) && Array.isArray(after)) {
977
- if (deepEqualCompat(before, after)) return
978
- const changedIndexes = getChangedArrayIndexes(before, after)
979
- if (before.length === after.length && changedIndexes.length === 1) {
980
- const index = changedIndexes[0]
978
+ const diff = arrayDiff(before, after, deepEqualCompat)
979
+ if (!diff.length) return
980
+ const index = getSingleArrayReplacementIndex(diff)
981
+ if (index != null) {
981
982
  await diffDeepCompat(getChildSignal($signal, index), before[index], after[index])
982
983
  return
983
984
  }
984
- await SignalCompat.prototype.set.call($signal, after)
985
+ await applyArrayDiffCompat($signal, diff)
985
986
  return
986
987
  }
987
988
 
@@ -1003,14 +1004,14 @@ function diffDeepCompatSync ($signal, before, after) {
1003
1004
  if (before === after) return
1004
1005
 
1005
1006
  if (Array.isArray(before) && Array.isArray(after)) {
1006
- if (deepEqualCompat(before, after)) return
1007
- const changedIndexes = getChangedArrayIndexes(before, after)
1008
- if (before.length === after.length && changedIndexes.length === 1) {
1009
- const index = changedIndexes[0]
1007
+ const diff = arrayDiff(before, after, deepEqualCompat)
1008
+ if (!diff.length) return
1009
+ const index = getSingleArrayReplacementIndex(diff)
1010
+ if (index != null) {
1010
1011
  diffDeepCompatSync(getChildSignal($signal, index), before[index], after[index])
1011
1012
  return
1012
1013
  }
1013
- setReplacePrivateCompatSync($signal, after)
1014
+ applyArrayDiffCompatSync($signal, diff)
1014
1015
  return
1015
1016
  }
1016
1017
 
@@ -1035,14 +1036,54 @@ function isDiffableObject (before, after) {
1035
1036
  return true
1036
1037
  }
1037
1038
 
1038
- function getChangedArrayIndexes (before, after) {
1039
- if (!Array.isArray(before) || !Array.isArray(after)) return []
1040
- const maxLength = Math.max(before.length, after.length)
1041
- const changed = []
1042
- for (let i = 0; i < maxLength; i++) {
1043
- if (!deepEqualCompat(before[i], after[i])) changed.push(i)
1039
+ function getSingleArrayReplacementIndex (diff) {
1040
+ if (!Array.isArray(diff) || diff.length !== 2) return null
1041
+ const first = diff[0]
1042
+ const second = diff[1]
1043
+ if (
1044
+ first instanceof arrayDiff.RemoveDiff &&
1045
+ second instanceof arrayDiff.InsertDiff &&
1046
+ first.index === second.index &&
1047
+ first.howMany === 1 &&
1048
+ second.values.length === 1
1049
+ ) {
1050
+ return first.index
1051
+ }
1052
+ return null
1053
+ }
1054
+
1055
+ async function applyArrayDiffCompat ($signal, diff) {
1056
+ for (const item of diff) {
1057
+ if (item instanceof arrayDiff.InsertDiff) {
1058
+ await arrayInsertOnSignal($signal, item.index, item.values)
1059
+ continue
1060
+ }
1061
+ if (item instanceof arrayDiff.RemoveDiff) {
1062
+ await arrayRemoveOnSignal($signal, item.index, item.howMany)
1063
+ continue
1064
+ }
1065
+ if (item instanceof arrayDiff.MoveDiff) {
1066
+ await arrayMoveOnSignal($signal, item.from, item.to, item.howMany)
1067
+ }
1068
+ }
1069
+ }
1070
+
1071
+ function applyArrayDiffCompatSync ($signal, diff) {
1072
+ const segments = ensureArrayTarget($signal)
1073
+ const rootId = getOwningRootId($signal)
1074
+ for (const item of diff) {
1075
+ if (item instanceof arrayDiff.InsertDiff) {
1076
+ arrayInsertPrivateData(rootId, segments, item.index, item.values)
1077
+ continue
1078
+ }
1079
+ if (item instanceof arrayDiff.RemoveDiff) {
1080
+ arrayRemovePrivateData(rootId, segments, item.index, item.howMany)
1081
+ continue
1082
+ }
1083
+ if (item instanceof arrayDiff.MoveDiff) {
1084
+ arrayMovePrivateData(rootId, segments, item.from, item.to, item.howMany)
1085
+ }
1044
1086
  }
1045
- return changed
1046
1087
  }
1047
1088
 
1048
1089
  function getChildSignal ($parent, key) {
@@ -1061,7 +1102,9 @@ function setReplacePrivateCompatSync ($signal, value) {
1061
1102
  value = normalizeIdFields(value, idFields, segments[1])
1062
1103
  }
1063
1104
  setReplacePrivateData(getOwningRootId($signal), segments, value)
1064
- if (isSilentContextActive()) mirrorRefMutationFromTarget(segments, value)
1105
+ if (shouldMirrorPrivateRefMutationLocally()) {
1106
+ mirrorRefMutationFromTarget(segments, value)
1107
+ }
1065
1108
  }
1066
1109
 
1067
1110
  function delPrivateCompatSync ($signal, options) {
@@ -1123,7 +1166,9 @@ async function setReplaceOnSignal ($signal, value) {
1123
1166
  }
1124
1167
  if (isPrivateMutationForbidden()) throw Error(ERRORS.publicOnly)
1125
1168
  const result = setReplacePrivateData(getOwningRootId($signal), segments, value)
1126
- if (isSilentContextActive()) mirrorRefMutationFromTarget(segments, value)
1169
+ if (shouldMirrorPrivateRefMutationLocally()) {
1170
+ mirrorRefMutationFromTarget(segments, value)
1171
+ }
1127
1172
  return result
1128
1173
  }
1129
1174
 
@@ -1271,6 +1316,11 @@ function shouldMirrorPublicRefMutationLocally (segments) {
1271
1316
  return !docSubscriptions.hasRuntime(transportHash)
1272
1317
  }
1273
1318
 
1319
+ function shouldMirrorPrivateRefMutationLocally () {
1320
+ if (isSilentContextActive()) return true
1321
+ return !isModelEventsEnabled()
1322
+ }
1323
+
1274
1324
  function shallowCopy (value) {
1275
1325
  const rawValue = raw(value)
1276
1326
  if (Array.isArray(rawValue)) return rawValue.slice()
@@ -24,6 +24,7 @@ export function compatStartOnRoot ($root, targetPath, ...depsAndGetter) {
24
24
  const existing = store.get(targetKey)
25
25
  if (existing) existing.stop()
26
26
 
27
+ let lastSourceSnapshot = UNSET
27
28
  const reaction = observe(() => {
28
29
  const resolvedDeps = []
29
30
  for (const dep of deps) {
@@ -38,13 +39,22 @@ export function compatStartOnRoot ($root, targetPath, ...depsAndGetter) {
38
39
  if (isThenable(err)) return
39
40
  throw err
40
41
  }
41
- const detachedValue = detachStartValue(nextValue)
42
+ const sourceSnapshot = detachStartValue(nextValue)
43
+ if (lastSourceSnapshot !== UNSET && deepEqualStartValue(lastSourceSnapshot, sourceSnapshot)) {
44
+ return
45
+ }
46
+ lastSourceSnapshot = sourceSnapshot
47
+ const detachedValue = detachStartValue(sourceSnapshot)
42
48
  // Keep the detached snapshot to avoid aliasing source and target.
43
49
  // Old racer start() writes through diffDeep by default. In compat mode we must preserve
44
50
  // that behavior, but also avoid reading the target reactively inside start(), otherwise
45
51
  // start() subscribes to its own output and local child edits get immediately overwritten.
46
52
  const maybePromise = $target.setDiffDeep(detachedValue)
47
- if (maybePromise?.catch) maybePromise.catch(ignorePromiseRejection)
53
+ if (maybePromise?.then) {
54
+ maybePromise
55
+ .then(() => {})
56
+ .catch(ignorePromiseRejection)
57
+ }
48
58
  }, { scheduler: scheduleReaction })
49
59
  store.set(targetKey, { stop: () => unobserve(reaction) })
50
60
  return $target
@@ -134,6 +144,8 @@ function isThenable (value) {
134
144
  return !!value && typeof value.then === 'function'
135
145
  }
136
146
 
147
+ const UNSET = Symbol('compat start unset')
148
+
137
149
  function detachStartValue (value) {
138
150
  const rawValue = raw(value)
139
151
  if (!rawValue || typeof rawValue !== 'object') return rawValue
@@ -166,3 +178,30 @@ function racerDeepCopy (value) {
166
178
  }
167
179
  return value
168
180
  }
181
+
182
+ function deepEqualStartValue (left, right) {
183
+ if (left === right) return true
184
+ if (Number.isNaN(left) && Number.isNaN(right)) return true
185
+ if (left instanceof Date || right instanceof Date) {
186
+ return left instanceof Date && right instanceof Date && left.getTime() === right.getTime()
187
+ }
188
+ if (!left || !right || typeof left !== 'object' || typeof right !== 'object') return false
189
+
190
+ if (Array.isArray(left) || Array.isArray(right)) {
191
+ if (!Array.isArray(left) || !Array.isArray(right)) return false
192
+ if (left.length !== right.length) return false
193
+ for (let i = 0; i < left.length; i++) {
194
+ if (!deepEqualStartValue(left[i], right[i])) return false
195
+ }
196
+ return true
197
+ }
198
+
199
+ const leftKeys = Object.keys(left)
200
+ const rightKeys = Object.keys(right)
201
+ if (leftKeys.length !== rightKeys.length) return false
202
+ for (const key of leftKeys) {
203
+ if (!Object.prototype.hasOwnProperty.call(right, key)) return false
204
+ if (!deepEqualStartValue(left[key], right[key])) return false
205
+ }
206
+ return true
207
+ }
package/orm/Doc.js CHANGED
@@ -35,6 +35,34 @@ function getOwningRootId ($doc) {
35
35
  return rootId
36
36
  }
37
37
 
38
+ function deepEqualDocData (left, right) {
39
+ if (left === right) return true
40
+ if (left == null || right == null) return left === right
41
+
42
+ const leftIsArray = Array.isArray(left)
43
+ if (leftIsArray || Array.isArray(right)) {
44
+ if (!leftIsArray || !Array.isArray(right)) return false
45
+ if (left.length !== right.length) return false
46
+ for (let i = 0; i < left.length; i++) {
47
+ if (!deepEqualDocData(left[i], right[i])) return false
48
+ }
49
+ return true
50
+ }
51
+
52
+ if (typeof left !== 'object' || typeof right !== 'object') return false
53
+
54
+ const leftKeys = Object.keys(left)
55
+ const rightKeys = Object.keys(right)
56
+ if (leftKeys.length !== rightKeys.length) return false
57
+
58
+ for (const key of leftKeys) {
59
+ if (!Object.prototype.hasOwnProperty.call(right, key)) return false
60
+ if (!deepEqualDocData(left[key], right[key])) return false
61
+ }
62
+
63
+ return true
64
+ }
65
+
38
66
  class Doc {
39
67
  initialized
40
68
 
@@ -162,6 +190,12 @@ class Doc {
162
190
  if (isPlainObject(doc.data)) injectIdFields(doc.data, idFields, this.docId)
163
191
  const path = [this.collection, this.docId]
164
192
  const data = isObservable(doc.data) ? raw(doc.data) : doc.data
193
+ const current = _getRaw(path)
194
+ if (deepEqualDocData(current, data)) {
195
+ if (current != null && current !== raw(doc.data)) doc.data = current
196
+ if (!isObservable(doc.data)) doc.data = observable(doc.data)
197
+ return
198
+ }
165
199
  _set(path, data)
166
200
  const synced = _getRaw(path)
167
201
  if (synced != null && synced !== raw(doc.data)) doc.data = synced
package/orm/dataTree.js CHANGED
@@ -73,7 +73,7 @@ export function set (segments, value, tree = dataTree, eventContext) {
73
73
  const shouldEmit = shouldEmitModelEvents(tree, eventContext)
74
74
  const prevValue = shouldEmit ? get(segments, getTreeRaw(tree)) : undefined
75
75
  let dataNode = writableTree
76
- let dataNodeRaw = raw(writableTree)
76
+ let dataNodeRaw = getTreeRaw(writableTree)
77
77
  for (let i = 0; i < segments.length - 1; i++) {
78
78
  const segment = segments[i]
79
79
  const nextSegment = segments[i + 1]
@@ -84,34 +84,15 @@ export function set (segments, value, tree = dataTree, eventContext) {
84
84
  else dataNode[segment] = {}
85
85
  }
86
86
  dataNode = dataNode[segment]
87
- dataNodeRaw = raw(dataNode)
87
+ dataNodeRaw = getTreeRaw(dataNode)
88
88
  }
89
89
  const key = segments[segments.length - 1]
90
- // handle adding out of bounds empty element to the array
91
- if (value == null && Array.isArray(dataNodeRaw) && key >= dataNodeRaw.length) {
92
- // inject new undefined elements to the end of the array
93
- dataNode.splice(dataNodeRaw.length, key - dataNodeRaw.length + 1,
94
- ...Array(key - dataNodeRaw.length + 1).fill(undefined))
95
- return
96
- }
97
- // handle when the value didn't change
98
- if (value === dataNodeRaw[key]) return
99
- // handle setting undefined value
100
- if (value == null) {
101
- if (Array.isArray(dataNodeRaw)) {
102
- // if parent is an array -- we set array element to undefined
103
- // IMPORTANT: JSON serialization will replace `undefined` with `null`
104
- // so if the data will go to the server, it will be serialized as `null`.
105
- // And when it comes back from the server it will be still `null`.
106
- // This can lead to confusion since when you set `undefined` the value
107
- // might end up becoming `null` for seemingly no reason (like in this case).
108
- dataNode[key] = undefined
109
- } else {
110
- // if parent is an object -- we completely delete the property.
111
- // Deleting the property is better for the JSON serialization
112
- // since JSON does not have `undefined` values and replaces them with `null`.
113
- delete dataNode[key]
114
- }
90
+ const keyExists = hasOwnDataKey(dataNodeRaw, key)
91
+ // Preserve racer local semantics: assigning undefined creates/keeps the slot/key
92
+ // instead of deleting it, and sparse array writes keep holes intact.
93
+ if (keyExists && value === dataNodeRaw[key]) return
94
+ if (value == null || typeof value !== 'object') {
95
+ dataNode[key] = value
115
96
  emitModelEvent(segments, prevValue, { op: 'set' }, tree, eventContext)
116
97
  return
117
98
  }
@@ -124,6 +105,12 @@ export function set (segments, value, tree = dataTree, eventContext) {
124
105
  emitModelEvent(segments, prevValue, { op: 'set' }, tree, eventContext)
125
106
  }
126
107
 
108
+ function hasOwnDataKey (node, key) {
109
+ if (node == null) return false
110
+ if (Array.isArray(node)) return key in node
111
+ return Object.prototype.hasOwnProperty.call(node, key)
112
+ }
113
+
127
114
  // Like set(), but always assigns the value without equality checks or delete-on-null behavior
128
115
  export function setReplace (segments, value, tree = dataTree, eventContext) {
129
116
  const writableTree = getWritableTree(tree)
@@ -253,7 +240,7 @@ export async function setPublicDoc (segments, value, deleteValue = false) {
253
240
  if (deleteValue) {
254
241
  del(segments.slice(2), newDoc)
255
242
  } else {
256
- set(segments.slice(2), value, newDoc)
243
+ set(segments.slice(2), normalizeUndefined(value), newDoc)
257
244
  }
258
245
  const diff = jsonDiff(oldDoc, newDoc, diffMatchPatch)
259
246
  return new Promise((resolve, reject) => {
@@ -326,6 +313,13 @@ export async function setPublicDocReplace (segments, value) {
326
313
  }
327
314
 
328
315
  const relativePath = segments.slice(2)
316
+ // json0 direct replace ops require every ancestor container to already exist.
317
+ // Racer-like compat set, however, materializes missing/primitive parents while
318
+ // descending into the path. Fall back to the older diff-based path when the
319
+ // direct op would target a non-existent/non-object ancestor.
320
+ if (!canApplyDirectReplaceOp(docState.snapshot || {}, relativePath)) {
321
+ return setPublicDoc(segments, value)
322
+ }
329
323
  const previous = getRaw(segments)
330
324
  const normalizedPrevious = normalizeUndefined(
331
325
  relativePath.length === 0 ? stripIdFields(previous, idFields) : previous
@@ -342,7 +336,13 @@ export async function setPublicDocReplace (segments, value) {
342
336
  return new Promise((resolve, reject) => {
343
337
  doc.submitOp(op, err => {
344
338
  if (err) return reject(err)
345
- ensureLocalDocSyncedWithShareDoc({ collection, docId, doc, idFields })
339
+ syncLocalDocAfterPublicWrite({
340
+ collection,
341
+ docId,
342
+ doc,
343
+ idFields,
344
+ relativePath
345
+ })
346
346
  resolve()
347
347
  })
348
348
  })
@@ -440,10 +440,47 @@ function ensureLocalDocSyncedWithShareDoc ({
440
440
  setReplace([collection, docId], shared)
441
441
  }
442
442
 
443
+ function syncLocalDocAfterPublicWrite ({
444
+ collection,
445
+ docId,
446
+ doc,
447
+ idFields,
448
+ relativePath = []
449
+ }) {
450
+ if (!Array.isArray(relativePath) || relativePath.length === 0) {
451
+ ensureLocalDocSyncedWithShareDoc({ collection, docId, doc, idFields })
452
+ return
453
+ }
454
+ if (isMissingShareDoc(doc)) return
455
+ if (doc?.data == null) return
456
+ const shared = raw(doc.data)
457
+ const nextValue = get(relativePath, shared)
458
+ setReplace([collection, docId, ...relativePath], clonePublicLocalSyncValue(nextValue))
459
+ }
460
+
461
+ function clonePublicLocalSyncValue (value) {
462
+ const rawValue = raw(value)
463
+ if (rawValue == null || typeof rawValue !== 'object') return rawValue
464
+ if (typeof globalThis.structuredClone === 'function') {
465
+ return globalThis.structuredClone(rawValue)
466
+ }
467
+ return JSON.parse(JSON.stringify(rawValue))
468
+ }
469
+
443
470
  function normalizeUndefined (value) {
444
471
  return value === undefined ? null : value
445
472
  }
446
473
 
474
+ function canApplyDirectReplaceOp (docSnapshot, relativePath) {
475
+ if (relativePath.length === 0) return true
476
+ let node = docSnapshot
477
+ for (let i = 0; i < relativePath.length - 1; i++) {
478
+ if (node == null || typeof node !== 'object') return false
479
+ node = node[relativePath[i]]
480
+ }
481
+ return node != null && typeof node === 'object'
482
+ }
483
+
447
484
  function normalizeValueForOp (value) {
448
485
  let result = raw(value)
449
486
  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.97",
3
+ "version": "0.4.0-alpha.99",
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": "633e39dc754332bcd3d95ddc4cfb3522f0bfd694"
86
+ "gitHead": "22d0358b80fa29754147b4318975b26fff3eae5a"
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