teamplay 0.4.0-alpha.2 → 0.4.0-alpha.21
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/orm/Compat/README.md +16 -4
- package/orm/Compat/SignalCompat.js +79 -14
- package/orm/Compat/hooksCompat.js +47 -17
- package/orm/Compat/modelEvents.js +2 -4
- package/orm/Compat/refFallback.js +60 -0
- package/orm/Doc.js +7 -4
- package/orm/Query.js +24 -2
- package/orm/Signal.js +2 -5
- package/orm/SignalBase.js +24 -2
- package/orm/compatEnv.js +4 -0
- package/orm/dataTree.js +6 -1
- package/orm/getSignal.js +32 -3
- package/package.json +8 -8
- package/react/promiseBatcher.js +27 -0
- package/react/trapRender.js +11 -4
- package/react/useSub.js +16 -2
- package/react/wrapIntoSuspense.js +1 -1
- package/utils/setDiffDeep.js +7 -0
- package/orm/Compat/REF.md +0 -315
package/orm/Compat/README.md
CHANGED
|
@@ -94,9 +94,11 @@ $.users.user1.name.parent(2) // $.users
|
|
|
94
94
|
Legacy path navigation. Accepts:
|
|
95
95
|
- string with dot path (`'a.b.c'`)
|
|
96
96
|
- integer index for arrays (`0`)
|
|
97
|
+
- multiple path segments (`'a', 'b', 0`)
|
|
97
98
|
|
|
98
99
|
```js
|
|
99
100
|
$.users.user1.at('profile.name')
|
|
101
|
+
$.users.user1.at('profile', 'name')
|
|
100
102
|
$.items.at(0)
|
|
101
103
|
```
|
|
102
104
|
|
|
@@ -106,6 +108,7 @@ Resolve a path from root, ignoring the current signal path.
|
|
|
106
108
|
|
|
107
109
|
```js
|
|
108
110
|
$.users.user1.scope('users.user2')
|
|
111
|
+
$.users.user1.scope('users', 'user2')
|
|
109
112
|
```
|
|
110
113
|
|
|
111
114
|
### ref(target) / ref(subpath, target)
|
|
@@ -228,20 +231,25 @@ await $$agg.subscribe()
|
|
|
228
231
|
const rows = $$agg.getExtra()
|
|
229
232
|
```
|
|
230
233
|
|
|
231
|
-
### get()
|
|
234
|
+
### get(subpath?)
|
|
232
235
|
|
|
233
236
|
Returns the current value and tracks reactivity.
|
|
234
237
|
|
|
235
238
|
```js
|
|
236
239
|
const name = $.users.user1.name.get()
|
|
240
|
+
$root.get('$render.url')
|
|
241
|
+
$user.get('profile.name')
|
|
242
|
+
$user.get('profile', 'name')
|
|
237
243
|
```
|
|
238
244
|
|
|
239
|
-
### peek()
|
|
245
|
+
### peek(subpath?)
|
|
240
246
|
|
|
241
247
|
Returns the current value **without** tracking reactivity.
|
|
242
248
|
|
|
243
249
|
```js
|
|
244
250
|
const name = $.users.user1.name.peek()
|
|
251
|
+
$user.peek('profile.name')
|
|
252
|
+
$user.peek('profile', 'name')
|
|
245
253
|
```
|
|
246
254
|
|
|
247
255
|
### getCopy(subpath)
|
|
@@ -669,10 +677,12 @@ This matches StartupJS and makes updates easy:
|
|
|
669
677
|
$users[userId].name.set('New Name')
|
|
670
678
|
```
|
|
671
679
|
|
|
672
|
-
`useQuery$` returns the
|
|
680
|
+
`useQuery$` returns the **query signal**:
|
|
673
681
|
|
|
674
682
|
```js
|
|
675
|
-
const $
|
|
683
|
+
const $query = useQuery$('users', { active: true })
|
|
684
|
+
const ids = $query.getIds()
|
|
685
|
+
const docs = $query.get()
|
|
676
686
|
```
|
|
677
687
|
|
|
678
688
|
If `query == null`, a warning is logged and `{ _id: '__NON_EXISTENT__' }` is used.
|
|
@@ -755,6 +765,8 @@ const Component = observer(() => {
|
|
|
755
765
|
```js
|
|
756
766
|
const Component = observer(() => {
|
|
757
767
|
const [users, $users] = useQuery('users', { active: true })
|
|
768
|
+
const $query = useQuery$('users', { active: true })
|
|
769
|
+
const ids = $query.getIds()
|
|
758
770
|
return (
|
|
759
771
|
<>
|
|
760
772
|
{users.map(u => <div key={u._id}>{u.name}</div>)}
|
|
@@ -40,11 +40,16 @@ import {
|
|
|
40
40
|
import { on as onCustomEvent, removeListener as removeCustomEventListener } from './eventsCompat.js'
|
|
41
41
|
import { normalizePattern, onModelEvent, removeModelListener } from './modelEvents.js'
|
|
42
42
|
import { setRefLink, removeRefLink } from './refRegistry.js'
|
|
43
|
+
import { REF_TARGET, resolveRefSignalSafe } from './refFallback.js'
|
|
43
44
|
|
|
44
45
|
class SignalCompat extends Signal {
|
|
45
46
|
static ID_FIELDS = ['_id', 'id']
|
|
46
47
|
static [GETTERS] = [...DEFAULT_GETTERS, 'getCopy', 'getDeepCopy']
|
|
47
48
|
|
|
49
|
+
get root () {
|
|
50
|
+
return this.scope()
|
|
51
|
+
}
|
|
52
|
+
|
|
48
53
|
path (subpath) {
|
|
49
54
|
if (arguments.length > 1) throw Error('Signal.path() expects a single argument')
|
|
50
55
|
if (arguments.length === 0) return super.path()
|
|
@@ -54,8 +59,9 @@ class SignalCompat extends Signal {
|
|
|
54
59
|
}
|
|
55
60
|
|
|
56
61
|
at (subpath) {
|
|
57
|
-
|
|
58
|
-
|
|
62
|
+
const segments = arguments.length > 1
|
|
63
|
+
? parseAtSegments(arguments, 'Signal.at()')
|
|
64
|
+
: parseAtSubpath(subpath, arguments.length, 'Signal.at()')
|
|
59
65
|
if (segments.length === 0) return this
|
|
60
66
|
let $cursor = this
|
|
61
67
|
for (const segment of segments) {
|
|
@@ -118,12 +124,46 @@ class SignalCompat extends Signal {
|
|
|
118
124
|
}
|
|
119
125
|
|
|
120
126
|
get () {
|
|
127
|
+
if (arguments.length > 1) {
|
|
128
|
+
const segments = parseAtSegments(arguments, 'Signal.get()')
|
|
129
|
+
const $base = resolveRefSignal(this)
|
|
130
|
+
const $target = resolveSignal($base, segments)
|
|
131
|
+
return Signal.prototype.get.call($target)
|
|
132
|
+
}
|
|
133
|
+
if (arguments.length === 1) {
|
|
134
|
+
if (arguments[0] == null) {
|
|
135
|
+
const $target = resolveRefSignal(this)
|
|
136
|
+
if ($target !== this) return Signal.prototype.get.apply($target, [])
|
|
137
|
+
return Signal.prototype.get.apply(this, [])
|
|
138
|
+
}
|
|
139
|
+
const segments = parseAtSubpath(arguments[0], 1, 'Signal.get()')
|
|
140
|
+
const $base = resolveRefSignal(this)
|
|
141
|
+
const $target = resolveSignal($base, segments)
|
|
142
|
+
return Signal.prototype.get.call($target)
|
|
143
|
+
}
|
|
121
144
|
const $target = resolveRefSignal(this)
|
|
122
145
|
if ($target !== this) return Signal.prototype.get.apply($target, arguments)
|
|
123
146
|
return Signal.prototype.get.apply(this, arguments)
|
|
124
147
|
}
|
|
125
148
|
|
|
126
149
|
peek () {
|
|
150
|
+
if (arguments.length > 1) {
|
|
151
|
+
const segments = parseAtSegments(arguments, 'Signal.peek()')
|
|
152
|
+
const $base = resolveRefSignal(this)
|
|
153
|
+
const $target = resolveSignal($base, segments)
|
|
154
|
+
return Signal.prototype.peek.call($target)
|
|
155
|
+
}
|
|
156
|
+
if (arguments.length === 1) {
|
|
157
|
+
if (arguments[0] == null) {
|
|
158
|
+
const $target = resolveRefSignal(this)
|
|
159
|
+
if ($target !== this) return Signal.prototype.peek.apply($target, [])
|
|
160
|
+
return Signal.prototype.peek.apply(this, [])
|
|
161
|
+
}
|
|
162
|
+
const segments = parseAtSubpath(arguments[0], 1, 'Signal.peek()')
|
|
163
|
+
const $base = resolveRefSignal(this)
|
|
164
|
+
const $target = resolveSignal($base, segments)
|
|
165
|
+
return Signal.prototype.peek.call($target)
|
|
166
|
+
}
|
|
127
167
|
const $target = resolveRefSignal(this)
|
|
128
168
|
if ($target !== this) return Signal.prototype.peek.apply($target, arguments)
|
|
129
169
|
return Signal.prototype.peek.apply(this, arguments)
|
|
@@ -143,6 +183,22 @@ class SignalCompat extends Signal {
|
|
|
143
183
|
return Signal.prototype.set.call($target, value)
|
|
144
184
|
}
|
|
145
185
|
|
|
186
|
+
async add (collectionOrValue, value) {
|
|
187
|
+
const isRoot = this[SEGMENTS].length === 0
|
|
188
|
+
const isRootCollectionCall = isRoot && typeof collectionOrValue === 'string'
|
|
189
|
+
|
|
190
|
+
if (isRootCollectionCall) {
|
|
191
|
+
if (arguments.length !== 2) throw Error('Signal.add() expects (collection, object)')
|
|
192
|
+
if (!value || typeof value !== 'object') throw Error('Signal.add() expects an object argument')
|
|
193
|
+
const $root = getRoot(this) || this
|
|
194
|
+
const $collection = resolveSignal($root, [collectionOrValue])
|
|
195
|
+
return $collection.add(value)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (arguments.length > 1) throw Error('Signal.add() expects a single argument')
|
|
199
|
+
return Signal.prototype.add.call(this, collectionOrValue)
|
|
200
|
+
}
|
|
201
|
+
|
|
146
202
|
async setNull (path, value) {
|
|
147
203
|
const forwarded = forwardRef(this, 'setNull', arguments)
|
|
148
204
|
if (forwarded) return forwarded
|
|
@@ -495,11 +551,11 @@ class SignalCompat extends Signal {
|
|
|
495
551
|
}
|
|
496
552
|
|
|
497
553
|
scope (path) {
|
|
498
|
-
if (arguments.length > 1) throw Error('Signal.scope() expects a single argument')
|
|
499
554
|
const $root = getRoot(this) || this
|
|
500
555
|
if (arguments.length === 0) return $root
|
|
501
|
-
|
|
502
|
-
|
|
556
|
+
const segments = arguments.length > 1
|
|
557
|
+
? parseAtSegments(arguments, 'Signal.scope()')
|
|
558
|
+
: parseAtSubpath(path, arguments.length, 'Signal.scope()')
|
|
503
559
|
if (segments.length === 0) return $root
|
|
504
560
|
let $cursor = $root
|
|
505
561
|
for (const segment of segments) {
|
|
@@ -510,7 +566,6 @@ class SignalCompat extends Signal {
|
|
|
510
566
|
}
|
|
511
567
|
|
|
512
568
|
const REFS = Symbol('compat refs')
|
|
513
|
-
const REF_TARGET = Symbol('compat ref target')
|
|
514
569
|
|
|
515
570
|
function getRefStore ($signal) {
|
|
516
571
|
const $root = getRoot($signal) || $signal
|
|
@@ -545,14 +600,7 @@ function trackDeep (value, seen = new Set()) {
|
|
|
545
600
|
}
|
|
546
601
|
|
|
547
602
|
function resolveRefSignal ($signal) {
|
|
548
|
-
|
|
549
|
-
const seen = new Set()
|
|
550
|
-
while (current && current[REF_TARGET]) {
|
|
551
|
-
if (seen.has(current)) break
|
|
552
|
-
seen.add(current)
|
|
553
|
-
current = current[REF_TARGET]
|
|
554
|
-
}
|
|
555
|
-
return current
|
|
603
|
+
return resolveRefSignalSafe($signal) || $signal
|
|
556
604
|
}
|
|
557
605
|
|
|
558
606
|
function forwardRef ($signal, methodName, args) {
|
|
@@ -586,6 +634,23 @@ function parseAtSubpath (subpath, argsLength, methodName) {
|
|
|
586
634
|
throw Error(`${methodName} expects a string or integer argument`)
|
|
587
635
|
}
|
|
588
636
|
|
|
637
|
+
function parseAtSegments (args, methodName) {
|
|
638
|
+
const segments = []
|
|
639
|
+
for (const arg of Array.from(args)) {
|
|
640
|
+
if (typeof arg === 'string') {
|
|
641
|
+
const parts = arg.split('.').filter(Boolean)
|
|
642
|
+
segments.push(...parts)
|
|
643
|
+
continue
|
|
644
|
+
}
|
|
645
|
+
if (typeof arg === 'number' && Number.isFinite(arg) && Number.isInteger(arg)) {
|
|
646
|
+
segments.push(arg)
|
|
647
|
+
continue
|
|
648
|
+
}
|
|
649
|
+
throw Error(`${methodName} expects string or integer path segments`)
|
|
650
|
+
}
|
|
651
|
+
return segments
|
|
652
|
+
}
|
|
653
|
+
|
|
589
654
|
function resolveSignal ($signal, segments) {
|
|
590
655
|
let $cursor = $signal
|
|
591
656
|
for (const segment of segments) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getRootSignal, GLOBAL_ROOT_ID } from '../Root.js'
|
|
2
2
|
import useSub, { useAsyncSub } from '../../react/useSub.js'
|
|
3
3
|
import universal$ from '../../react/universal$.js'
|
|
4
|
+
import * as promiseBatcher from '../../react/promiseBatcher.js'
|
|
4
5
|
|
|
5
6
|
const $root = getRootSignal({ rootId: GLOBAL_ROOT_ID, rootFunction: universal$ })
|
|
6
7
|
|
|
@@ -72,8 +73,10 @@ export function usePage (path) {
|
|
|
72
73
|
return useLocal(prefixLocalPath('_page', path))
|
|
73
74
|
}
|
|
74
75
|
|
|
75
|
-
|
|
76
|
-
|
|
76
|
+
export function useBatch () {
|
|
77
|
+
const promise = promiseBatcher.getPromiseAll()
|
|
78
|
+
if (promise) throw promise
|
|
79
|
+
}
|
|
77
80
|
|
|
78
81
|
export function useDoc$ (collection, id, options) {
|
|
79
82
|
const $doc = getDocSignal(collection, id, 'useDoc')
|
|
@@ -86,13 +89,15 @@ export function useDoc (collection, id, options) {
|
|
|
86
89
|
return [$doc.get(), $doc]
|
|
87
90
|
}
|
|
88
91
|
|
|
89
|
-
// Batch variants are aliases to non-batch versions (no batching in teamplay).
|
|
90
92
|
export function useBatchDoc (collection, id, options) {
|
|
91
|
-
|
|
93
|
+
const $doc = useBatchDoc$(collection, id, options)
|
|
94
|
+
if (!$doc) return [undefined, undefined]
|
|
95
|
+
return [$doc.get(), $doc]
|
|
92
96
|
}
|
|
93
97
|
|
|
94
|
-
export function useBatchDoc$ (collection, id,
|
|
95
|
-
|
|
98
|
+
export function useBatchDoc$ (collection, id, _options) {
|
|
99
|
+
const $doc = getDocSignal(collection, id, 'useBatchDoc')
|
|
100
|
+
return useSub($doc, undefined, { async: false, batch: true })
|
|
96
101
|
}
|
|
97
102
|
|
|
98
103
|
export function useAsyncDoc$ (collection, id, options) {
|
|
@@ -109,8 +114,8 @@ export function useAsyncDoc (collection, id, options) {
|
|
|
109
114
|
export function useQuery$ (collection, query, options) {
|
|
110
115
|
const $collection = getCollectionSignal(collection, query, 'useQuery')
|
|
111
116
|
const normalizedOptions = options ? { ...options, async: false } : options
|
|
112
|
-
useSub($collection, normalizeQuery(query, 'useQuery'), normalizedOptions)
|
|
113
|
-
return $
|
|
117
|
+
const $query = useSub($collection, normalizeQuery(query, 'useQuery'), normalizedOptions)
|
|
118
|
+
return $query
|
|
114
119
|
}
|
|
115
120
|
|
|
116
121
|
export function useQuery (collection, query, options) {
|
|
@@ -122,8 +127,8 @@ export function useQuery (collection, query, options) {
|
|
|
122
127
|
|
|
123
128
|
export function useAsyncQuery$ (collection, query, options) {
|
|
124
129
|
const $collection = getCollectionSignal(collection, query, 'useAsyncQuery')
|
|
125
|
-
useAsyncSub($collection, normalizeQuery(query, 'useAsyncQuery'), options)
|
|
126
|
-
return $
|
|
130
|
+
const $query = useAsyncSub($collection, normalizeQuery(query, 'useAsyncQuery'), options)
|
|
131
|
+
return $query
|
|
127
132
|
}
|
|
128
133
|
|
|
129
134
|
export function useAsyncQuery (collection, query, options) {
|
|
@@ -133,13 +138,16 @@ export function useAsyncQuery (collection, query, options) {
|
|
|
133
138
|
return [$query.get(), $collection]
|
|
134
139
|
}
|
|
135
140
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
return
|
|
141
|
+
export function useBatchQuery$ (collection, query, _options) {
|
|
142
|
+
const $collection = getCollectionSignal(collection, query, 'useBatchQuery')
|
|
143
|
+
return useSub($collection, normalizeQuery(query, 'useBatchQuery'), { async: false, batch: true })
|
|
139
144
|
}
|
|
140
145
|
|
|
141
146
|
export function useBatchQuery (collection, query, options) {
|
|
142
|
-
|
|
147
|
+
const $collection = getCollectionSignal(collection, query, 'useBatchQuery')
|
|
148
|
+
const $query = useBatchQuery$(collection, query, options)
|
|
149
|
+
if (!$query) return [undefined, $collection]
|
|
150
|
+
return [$query.get(), $collection]
|
|
143
151
|
}
|
|
144
152
|
|
|
145
153
|
export function useQueryIds (collection, ids = [], options = {}) {
|
|
@@ -157,7 +165,17 @@ export function useQueryIds (collection, ids = [], options = {}) {
|
|
|
157
165
|
}
|
|
158
166
|
|
|
159
167
|
export function useBatchQueryIds (collection, ids = [], options = {}) {
|
|
160
|
-
|
|
168
|
+
const list = Array.isArray(ids) ? ids.slice() : []
|
|
169
|
+
if (options?.reverse) list.reverse()
|
|
170
|
+
const [docs, $collection] = useBatchQuery(collection, { _id: { $in: list } }, options)
|
|
171
|
+
if (!docs) return [docs, $collection]
|
|
172
|
+
const docsById = new Map()
|
|
173
|
+
for (const doc of docs) {
|
|
174
|
+
const id = doc?._id ?? doc?.id
|
|
175
|
+
if (id != null) docsById.set(id, doc)
|
|
176
|
+
}
|
|
177
|
+
const items = list.map(id => docsById.get(id)).filter(Boolean)
|
|
178
|
+
return [items, $collection]
|
|
161
179
|
}
|
|
162
180
|
|
|
163
181
|
export function useAsyncQueryIds (collection, ids = [], options = {}) {
|
|
@@ -194,11 +212,23 @@ export function useQueryDoc$ (collection, query, options) {
|
|
|
194
212
|
}
|
|
195
213
|
|
|
196
214
|
export function useBatchQueryDoc (collection, query, options) {
|
|
197
|
-
|
|
215
|
+
const normalized = normalizeQuery(query, 'useBatchQueryDoc')
|
|
216
|
+
const queryDoc = {
|
|
217
|
+
...normalized,
|
|
218
|
+
$limit: 1,
|
|
219
|
+
$sort: normalized.$sort || { createdAt: -1 }
|
|
220
|
+
}
|
|
221
|
+
const [docs, $collection] = useBatchQuery(collection, queryDoc, options)
|
|
222
|
+
if (!docs) return [undefined, undefined]
|
|
223
|
+
const doc = docs && docs[0]
|
|
224
|
+
const docId = doc?._id ?? doc?.id
|
|
225
|
+
const $doc = docId != null ? $collection[docId] : undefined
|
|
226
|
+
return [doc, $doc]
|
|
198
227
|
}
|
|
199
228
|
|
|
200
229
|
export function useBatchQueryDoc$ (collection, query, options) {
|
|
201
|
-
|
|
230
|
+
const [, $doc] = useBatchQueryDoc(collection, query, options)
|
|
231
|
+
return $doc
|
|
202
232
|
}
|
|
203
233
|
|
|
204
234
|
export function useAsyncQueryDoc (collection, query, options) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getRefLinks } from './refRegistry.js'
|
|
2
|
+
import { isCompatEnv } from '../compatEnv.js'
|
|
2
3
|
|
|
3
4
|
const modelListeners = {
|
|
4
5
|
change: new Map(),
|
|
@@ -6,10 +7,7 @@ const modelListeners = {
|
|
|
6
7
|
}
|
|
7
8
|
|
|
8
9
|
export function isModelEventsEnabled () {
|
|
9
|
-
return (
|
|
10
|
-
globalThis?.teamplayCompatibilityMode ??
|
|
11
|
-
(typeof process !== 'undefined' && process?.env?.TEAMPLAY_COMPAT === '1')
|
|
12
|
-
)
|
|
10
|
+
return isCompatEnv()
|
|
13
11
|
}
|
|
14
12
|
|
|
15
13
|
export function normalizePattern (pattern, methodName) {
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { getRefLinks } from './refRegistry.js'
|
|
2
|
+
|
|
3
|
+
export const REF_TARGET = Symbol.for('teamplay.compat.refTarget')
|
|
4
|
+
|
|
5
|
+
export function resolveRefSignalSafe ($signal, maxDepth = 32) {
|
|
6
|
+
let current = $signal
|
|
7
|
+
const seen = new Set()
|
|
8
|
+
for (let i = 0; i < maxDepth; i++) {
|
|
9
|
+
if (!current) return undefined
|
|
10
|
+
const next = current[REF_TARGET]
|
|
11
|
+
if (!next) return current
|
|
12
|
+
if (seen.has(current)) return undefined
|
|
13
|
+
seen.add(current)
|
|
14
|
+
current = next
|
|
15
|
+
}
|
|
16
|
+
return undefined
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function resolveRefSegmentsSafe (segments, maxDepth = 32) {
|
|
20
|
+
if (!Array.isArray(segments) || segments.length === 0) return undefined
|
|
21
|
+
let current = [...segments]
|
|
22
|
+
const visited = new Set([toPathKey(current)])
|
|
23
|
+
let changed = false
|
|
24
|
+
|
|
25
|
+
for (let i = 0; i < maxDepth; i++) {
|
|
26
|
+
const link = findBestMatchingLink(current)
|
|
27
|
+
if (!link) return changed ? current : undefined
|
|
28
|
+
const suffix = current.slice(link.fromSegments.length)
|
|
29
|
+
const next = link.toSegments.concat(suffix)
|
|
30
|
+
const key = toPathKey(next)
|
|
31
|
+
if (visited.has(key)) return undefined
|
|
32
|
+
visited.add(key)
|
|
33
|
+
current = next
|
|
34
|
+
changed = true
|
|
35
|
+
}
|
|
36
|
+
return undefined
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function findBestMatchingLink (segments) {
|
|
40
|
+
let best
|
|
41
|
+
for (const link of getRefLinks().values()) {
|
|
42
|
+
if (!isPathPrefix(link.fromSegments, segments)) continue
|
|
43
|
+
if (!best || link.fromSegments.length > best.fromSegments.length) {
|
|
44
|
+
best = link
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return best
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isPathPrefix (prefixSegments, fullSegments) {
|
|
51
|
+
if (prefixSegments.length > fullSegments.length) return false
|
|
52
|
+
for (let i = 0; i < prefixSegments.length; i++) {
|
|
53
|
+
if (prefixSegments[i] !== String(fullSegments[i])) return false
|
|
54
|
+
}
|
|
55
|
+
return true
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function toPathKey (segments) {
|
|
59
|
+
return segments.map(segment => String(segment)).join('.')
|
|
60
|
+
}
|
package/orm/Doc.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isObservable, observable } from '@nx-js/observer-util'
|
|
1
|
+
import { isObservable, observable, raw } from '@nx-js/observer-util'
|
|
2
2
|
import { set as _set, del as _del, getRaw as _getRaw } from './dataTree.js'
|
|
3
3
|
import { SEGMENTS } from './Signal.js'
|
|
4
4
|
import { getConnection, fetchOnly } from './connection.js'
|
|
@@ -90,9 +90,12 @@ class Doc {
|
|
|
90
90
|
if (doc.data == null) return
|
|
91
91
|
const idFields = getIdFieldsForSegments([this.collection, this.docId])
|
|
92
92
|
if (isPlainObject(doc.data)) injectIdFields(doc.data, idFields, this.docId)
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
93
|
+
const hasRaw = _getRaw([this.collection, this.docId]) != null
|
|
94
|
+
if (!hasRaw) {
|
|
95
|
+
const data = isObservable(doc.data) ? raw(doc.data) : doc.data
|
|
96
|
+
_set([this.collection, this.docId], data)
|
|
97
|
+
}
|
|
98
|
+
if (!isObservable(doc.data)) doc.data = observable(doc.data)
|
|
96
99
|
}
|
|
97
100
|
|
|
98
101
|
_removeData () {
|
package/orm/Query.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { raw } from '@nx-js/observer-util'
|
|
2
|
-
import { get as _get, set as _set, del as _del } from './dataTree.js'
|
|
2
|
+
import { get as _get, set as _set, del as _del, getRaw } from './dataTree.js'
|
|
3
3
|
import getSignal from './getSignal.js'
|
|
4
4
|
import { getConnection, fetchOnly } from './connection.js'
|
|
5
5
|
import { emitModelChange, isModelEventsEnabled } from './Compat/modelEvents.js'
|
|
6
|
+
import { isCompatEnv } from './compatEnv.js'
|
|
6
7
|
import { docSubscriptions } from './Doc.js'
|
|
7
8
|
import FinalizationRegistry from '../utils/MockFinalizationRegistry.js'
|
|
8
9
|
import SubscriptionState from './SubscriptionState.js'
|
|
@@ -76,6 +77,7 @@ export class Query {
|
|
|
76
77
|
|
|
77
78
|
_initData () {
|
|
78
79
|
{ // reference the fetched docs
|
|
80
|
+
maybeMaterializeQueryDocsToCollection(this.collectionName, this.shareQuery.results)
|
|
79
81
|
const docs = this.shareQuery.results.map(doc => {
|
|
80
82
|
const idFields = getIdFieldsForSegments([this.collectionName, doc.id])
|
|
81
83
|
if (isPlainObject(doc.data)) injectIdFields(doc.data, idFields, doc.id)
|
|
@@ -98,6 +100,7 @@ export class Query {
|
|
|
98
100
|
}
|
|
99
101
|
|
|
100
102
|
this.shareQuery.on('insert', (shareDocs, index) => {
|
|
103
|
+
maybeMaterializeQueryDocsToCollection(this.collectionName, shareDocs)
|
|
101
104
|
const newDocs = shareDocs.map(doc => {
|
|
102
105
|
const idFields = getIdFieldsForSegments([this.collectionName, doc.id])
|
|
103
106
|
if (isPlainObject(doc.data)) injectIdFields(doc.data, idFields, doc.id)
|
|
@@ -218,7 +221,13 @@ export class QuerySubscriptions {
|
|
|
218
221
|
let count = this.subCount.get(hash) || 0
|
|
219
222
|
count += 1
|
|
220
223
|
this.subCount.set(hash, count)
|
|
221
|
-
if (count > 1)
|
|
224
|
+
if (count > 1) {
|
|
225
|
+
const existingQuery = this.queries.get(hash)
|
|
226
|
+
if (existingQuery) return existingQuery._subscribing
|
|
227
|
+
// Recover from stale ref-count state when query was already cleaned up.
|
|
228
|
+
count = 1
|
|
229
|
+
this.subCount.set(hash, count)
|
|
230
|
+
}
|
|
222
231
|
|
|
223
232
|
this.fr.register($query, { collectionName, params }, $query)
|
|
224
233
|
|
|
@@ -246,6 +255,7 @@ export class QuerySubscriptions {
|
|
|
246
255
|
this.subCount.delete(hash)
|
|
247
256
|
this.fr.unregister($query)
|
|
248
257
|
const query = this.queries.get(hash)
|
|
258
|
+
if (!query) return
|
|
249
259
|
await query.unsubscribe()
|
|
250
260
|
if (query.subscribed) return // if we subscribed again while waiting for unsubscribe, we don't delete the doc
|
|
251
261
|
this.queries.delete(hash)
|
|
@@ -264,6 +274,18 @@ export class QuerySubscriptions {
|
|
|
264
274
|
|
|
265
275
|
export const querySubscriptions = new QuerySubscriptions()
|
|
266
276
|
|
|
277
|
+
function maybeMaterializeQueryDocsToCollection (collectionName, shareDocs) {
|
|
278
|
+
if (!isCompatEnv()) return
|
|
279
|
+
for (const doc of shareDocs) {
|
|
280
|
+
if (!doc?.id || doc.data == null) continue
|
|
281
|
+
const existing = getRaw([collectionName, doc.id])
|
|
282
|
+
if (existing != null) continue
|
|
283
|
+
const idFields = getIdFieldsForSegments([collectionName, doc.id])
|
|
284
|
+
if (isPlainObject(doc.data)) injectIdFields(doc.data, idFields, doc.id)
|
|
285
|
+
_set([collectionName, doc.id], raw(doc.data))
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
267
289
|
export function hashQuery (collectionName, params) {
|
|
268
290
|
// TODO: probably makes sense to use fast-stable-json-stringify for this because of the params
|
|
269
291
|
return JSON.stringify({ query: [collectionName, params] })
|
package/orm/Signal.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Signal } from './SignalBase.js'
|
|
2
2
|
import SignalCompat from './Compat/SignalCompat.js'
|
|
3
|
+
import { isCompatEnv } from './compatEnv.js'
|
|
3
4
|
|
|
4
5
|
export {
|
|
5
6
|
Signal,
|
|
@@ -18,8 +19,4 @@ export {
|
|
|
18
19
|
|
|
19
20
|
export { SignalCompat }
|
|
20
21
|
|
|
21
|
-
|
|
22
|
-
globalThis?.teamplayCompatibilityMode ??
|
|
23
|
-
(typeof process !== 'undefined' && process?.env?.TEAMPLAY_COMPAT === '1')
|
|
24
|
-
|
|
25
|
-
export default compatEnv ? SignalCompat : Signal
|
|
22
|
+
export default isCompatEnv() ? SignalCompat : Signal
|
package/orm/SignalBase.js
CHANGED
|
@@ -46,6 +46,8 @@ import { AGGREGATIONS, IS_AGGREGATION, getAggregationCollectionName, getAggregat
|
|
|
46
46
|
import { ROOT_FUNCTION, getRoot } from './Root.js'
|
|
47
47
|
import { publicOnly } from './connection.js'
|
|
48
48
|
import { DEFAULT_ID_FIELDS, getIdFieldsForSegments, isIdFieldPath, normalizeIdFields } from './idFields.js'
|
|
49
|
+
import { isCompatEnv } from './compatEnv.js'
|
|
50
|
+
import { resolveRefSegmentsSafe, resolveRefSignalSafe } from './Compat/refFallback.js'
|
|
49
51
|
|
|
50
52
|
export const SEGMENTS = Symbol('path segments targeting the particular node in the data tree')
|
|
51
53
|
export const ARRAY_METHOD = Symbol('run array method on the signal')
|
|
@@ -501,8 +503,28 @@ export const extremelyLateBindings = {
|
|
|
501
503
|
}
|
|
502
504
|
const $parent = getSignal(getRoot(signal), segments)
|
|
503
505
|
const rawParent = rawSignal($parent)
|
|
504
|
-
if (
|
|
505
|
-
|
|
506
|
+
if (key in rawParent) return Reflect.apply(rawParent[key], $parent, argumentsList)
|
|
507
|
+
|
|
508
|
+
if (isCompatEnv()) {
|
|
509
|
+
const $resolvedParent = resolveRefSignalSafe($parent)
|
|
510
|
+
if ($resolvedParent && $resolvedParent !== $parent) {
|
|
511
|
+
const rawResolvedParent = rawSignal($resolvedParent)
|
|
512
|
+
if (rawResolvedParent && key in rawResolvedParent) {
|
|
513
|
+
return Reflect.apply(rawResolvedParent[key], $resolvedParent, argumentsList)
|
|
514
|
+
}
|
|
515
|
+
} else {
|
|
516
|
+
const resolvedSegments = resolveRefSegmentsSafe(segments)
|
|
517
|
+
if (resolvedSegments) {
|
|
518
|
+
const $resolvedByPath = getSignal(getRoot(signal), resolvedSegments)
|
|
519
|
+
const rawResolvedByPath = rawSignal($resolvedByPath)
|
|
520
|
+
if (rawResolvedByPath && key in rawResolvedByPath) {
|
|
521
|
+
return Reflect.apply(rawResolvedByPath[key], $resolvedByPath, argumentsList)
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
throw Error(ERRORS.noSignalKey($parent, key))
|
|
506
528
|
},
|
|
507
529
|
get (signal, key, receiver) {
|
|
508
530
|
if (typeof key === 'symbol') return Reflect.get(signal, key, receiver)
|
package/orm/compatEnv.js
ADDED
package/orm/dataTree.js
CHANGED
|
@@ -186,7 +186,12 @@ export async function setPublicDoc (segments, value, deleteValue = false) {
|
|
|
186
186
|
})
|
|
187
187
|
} else {
|
|
188
188
|
// > modify existing doc. Partial doc modification
|
|
189
|
-
|
|
189
|
+
let oldDoc = getRaw([collection, docId])
|
|
190
|
+
if (oldDoc == null) {
|
|
191
|
+
const docData = getConnection().get(collection, docId).data
|
|
192
|
+
oldDoc = docData == null ? {} : raw(docData)
|
|
193
|
+
if (docData != null) set([collection, docId], oldDoc)
|
|
194
|
+
}
|
|
190
195
|
const newDoc = JSON.parse(JSON.stringify(oldDoc))
|
|
191
196
|
if (deleteValue) {
|
|
192
197
|
del(segments.slice(2), newDoc)
|
package/orm/getSignal.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import Cache from './Cache.js'
|
|
2
|
-
import Signal, { regularBindings, extremelyLateBindings, isPublicCollection, isPrivateCollection } from './Signal.js'
|
|
2
|
+
import Signal, { SEGMENTS, regularBindings, extremelyLateBindings, isPublicCollection, isPrivateCollection } from './Signal.js'
|
|
3
3
|
import { findModel } from './addModel.js'
|
|
4
4
|
import { LOCAL } from './$.js'
|
|
5
5
|
import { ROOT, ROOT_ID, GLOBAL_ROOT_ID } from './Root.js'
|
|
6
6
|
import { QUERIES } from './Query.js'
|
|
7
7
|
import { AGGREGATIONS } from './Aggregation.js'
|
|
8
|
+
import { isCompatEnv } from './compatEnv.js'
|
|
9
|
+
import { getConnection } from './connection.js'
|
|
10
|
+
import { resolveRefSegmentsSafe } from './Compat/refFallback.js'
|
|
8
11
|
|
|
9
12
|
const PROXIES_CACHE = new Cache()
|
|
10
13
|
const PROXY_TO_SIGNAL = new WeakMap()
|
|
@@ -48,6 +51,9 @@ export default function getSignal ($root, segments = [], {
|
|
|
48
51
|
// but without it calling the methods of root signal like $.get() doesn't work
|
|
49
52
|
proxy[ROOT] = $root || getSignal(undefined, [], { rootId: GLOBAL_ROOT_ID })
|
|
50
53
|
}
|
|
54
|
+
signal[ROOT] = proxy[ROOT]
|
|
55
|
+
} else {
|
|
56
|
+
signal[ROOT] = proxy
|
|
51
57
|
}
|
|
52
58
|
PROXY_TO_SIGNAL.set(proxy, signal)
|
|
53
59
|
const dependencies = []
|
|
@@ -69,7 +75,22 @@ export default function getSignal ($root, segments = [], {
|
|
|
69
75
|
}
|
|
70
76
|
|
|
71
77
|
function getDefaultProxyHandlers ({ useExtremelyLateBindings } = {}) {
|
|
72
|
-
|
|
78
|
+
const baseHandlers = useExtremelyLateBindings ? extremelyLateBindings : regularBindings
|
|
79
|
+
if (!isCompatEnv() || baseHandlers !== extremelyLateBindings) return baseHandlers
|
|
80
|
+
return {
|
|
81
|
+
...baseHandlers,
|
|
82
|
+
get (signal, key, receiver) {
|
|
83
|
+
if (key === 'connection' && signal[SEGMENTS].length === 0) {
|
|
84
|
+
try {
|
|
85
|
+
return getConnection()
|
|
86
|
+
} catch {
|
|
87
|
+
return undefined
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (key === 'root') return Reflect.get(signal, key, receiver)
|
|
91
|
+
return baseHandlers.get(signal, key, receiver)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
73
94
|
}
|
|
74
95
|
|
|
75
96
|
function hashSegments (segments, rootId) {
|
|
@@ -85,7 +106,15 @@ function hashSegments (segments, rootId) {
|
|
|
85
106
|
}
|
|
86
107
|
|
|
87
108
|
export function getSignalClass (segments) {
|
|
88
|
-
|
|
109
|
+
let Model = findModel(segments)
|
|
110
|
+
if (Model) return Model
|
|
111
|
+
if (!isCompatEnv()) return Signal
|
|
112
|
+
const dereferencedSegments = resolveRefSegmentsSafe(segments)
|
|
113
|
+
if (dereferencedSegments) {
|
|
114
|
+
Model = findModel(dereferencedSegments)
|
|
115
|
+
if (Model) return Model
|
|
116
|
+
}
|
|
117
|
+
return Signal
|
|
89
118
|
}
|
|
90
119
|
|
|
91
120
|
export function rawSignal (proxy) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "teamplay",
|
|
3
|
-
"version": "0.4.0-alpha.
|
|
3
|
+
"version": "0.4.0-alpha.21",
|
|
4
4
|
"description": "Full-stack signals ORM with multiplayer",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -34,12 +34,12 @@
|
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"@nx-js/observer-util": "^4.1.3",
|
|
36
36
|
"@startupjs/sharedb-mingo-memory": "^4.0.0-2",
|
|
37
|
-
"@teamplay/backend": "^0.4.0-alpha.
|
|
38
|
-
"@teamplay/cache": "^0.4.0-alpha.
|
|
39
|
-
"@teamplay/channel": "^0.4.0-alpha.
|
|
40
|
-
"@teamplay/debug": "^0.4.0-alpha.
|
|
41
|
-
"@teamplay/schema": "^0.4.0-alpha.
|
|
42
|
-
"@teamplay/utils": "^0.4.0-alpha.
|
|
37
|
+
"@teamplay/backend": "^0.4.0-alpha.9",
|
|
38
|
+
"@teamplay/cache": "^0.4.0-alpha.9",
|
|
39
|
+
"@teamplay/channel": "^0.4.0-alpha.9",
|
|
40
|
+
"@teamplay/debug": "^0.4.0-alpha.9",
|
|
41
|
+
"@teamplay/schema": "^0.4.0-alpha.9",
|
|
42
|
+
"@teamplay/utils": "^0.4.0-alpha.9",
|
|
43
43
|
"diff-match-patch": "^1.0.5",
|
|
44
44
|
"events": "^3.3.0",
|
|
45
45
|
"json0-ot-diff": "^1.1.2",
|
|
@@ -81,5 +81,5 @@
|
|
|
81
81
|
]
|
|
82
82
|
},
|
|
83
83
|
"license": "MIT",
|
|
84
|
-
"gitHead": "
|
|
84
|
+
"gitHead": "5b440dc6e2e114f2cfaac81d4757b710f153ff5d"
|
|
85
85
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
let active = false
|
|
2
|
+
let promises = []
|
|
3
|
+
|
|
4
|
+
export function activate () {
|
|
5
|
+
active = true
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function add (promise) {
|
|
9
|
+
if (!promise || typeof promise.then !== 'function') return
|
|
10
|
+
promises.push(promise)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function getPromiseAll () {
|
|
14
|
+
const hasPromises = promises.length > 0
|
|
15
|
+
const result = hasPromises ? Promise.all(promises) : null
|
|
16
|
+
reset()
|
|
17
|
+
return result
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function isActive () {
|
|
21
|
+
return active
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function reset () {
|
|
25
|
+
active = false
|
|
26
|
+
promises = []
|
|
27
|
+
}
|
package/react/trapRender.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// trap render function (functional component) to block observer updates and activate cache
|
|
2
2
|
// during synchronous rendering
|
|
3
3
|
import executionContextTracker from './executionContextTracker.js'
|
|
4
|
+
import * as promiseBatcher from './promiseBatcher.js'
|
|
4
5
|
|
|
5
6
|
export default function trapRender ({ render, cache, destroy, componentId }) {
|
|
6
7
|
return (...args) => {
|
|
@@ -9,13 +10,14 @@ export default function trapRender ({ render, cache, destroy, componentId }) {
|
|
|
9
10
|
let destroyed
|
|
10
11
|
try {
|
|
11
12
|
// destroyer.reset() // TODO: this one is for any destructuring logic which might be needed
|
|
12
|
-
|
|
13
|
+
promiseBatcher.reset()
|
|
13
14
|
const res = render(...args)
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
if (isDevMode() && promiseBatcher.isActive()) {
|
|
16
|
+
throw Error('[teamplay] useBatch* hooks were used without a closing useBatch() call.')
|
|
17
|
+
}
|
|
17
18
|
return res
|
|
18
19
|
} catch (err) {
|
|
20
|
+
promiseBatcher.reset()
|
|
19
21
|
// TODO: this might only be needed only if promise is thrown
|
|
20
22
|
// (check if useUnmount in convertToObserver is called if a regular error is thrown)
|
|
21
23
|
destroy('trapRender.js')
|
|
@@ -37,3 +39,8 @@ export default function trapRender ({ render, cache, destroy, componentId }) {
|
|
|
37
39
|
}
|
|
38
40
|
}
|
|
39
41
|
}
|
|
42
|
+
|
|
43
|
+
function isDevMode () {
|
|
44
|
+
if (typeof process === 'undefined' || !process?.env) return true
|
|
45
|
+
return process.env.NODE_ENV !== 'production'
|
|
46
|
+
}
|
package/react/useSub.js
CHANGED
|
@@ -2,6 +2,7 @@ import { useRef, useDeferredValue } from 'react'
|
|
|
2
2
|
import sub from '../orm/sub.js'
|
|
3
3
|
import { useScheduleUpdate, useCache, useDefer } from './helpers.js'
|
|
4
4
|
import executionContextTracker from './executionContextTracker.js'
|
|
5
|
+
import * as promiseBatcher from './promiseBatcher.js'
|
|
5
6
|
|
|
6
7
|
let TEST_THROTTLING = false
|
|
7
8
|
|
|
@@ -24,10 +25,11 @@ export default function useSub (signal, params, options) {
|
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
// version of sub() which works as a react hook and throws promise for Suspense
|
|
27
|
-
export function useSubDeferred (signal, params, { async = false, defer } = {}) {
|
|
28
|
+
export function useSubDeferred (signal, params, { async = false, defer, batch = false } = {}) {
|
|
28
29
|
const $signalRef = useRef() // eslint-disable-line react-hooks/rules-of-hooks
|
|
29
30
|
const scheduleUpdate = useScheduleUpdate()
|
|
30
31
|
const observerDefer = useDefer()
|
|
32
|
+
if (batch) promiseBatcher.activate()
|
|
31
33
|
defer ??= observerDefer ?? DEFAULT_DEFER
|
|
32
34
|
if (defer) {
|
|
33
35
|
signal = useDeferredValue(signal) // eslint-disable-line react-hooks/rules-of-hooks
|
|
@@ -38,6 +40,11 @@ export function useSubDeferred (signal, params, { async = false, defer } = {}) {
|
|
|
38
40
|
// 1. if it's a promise, throw it so that Suspense can catch it and wait for subscription to finish
|
|
39
41
|
if (promiseOrSignal.then) {
|
|
40
42
|
const promise = maybeThrottle(promiseOrSignal)
|
|
43
|
+
if (batch) {
|
|
44
|
+
promiseBatcher.add(promise)
|
|
45
|
+
if (async) scheduleUpdate(promise)
|
|
46
|
+
return $signalRef.current
|
|
47
|
+
}
|
|
41
48
|
if (async) {
|
|
42
49
|
scheduleUpdate(promise)
|
|
43
50
|
return
|
|
@@ -53,15 +60,22 @@ export function useSubDeferred (signal, params, { async = false, defer } = {}) {
|
|
|
53
60
|
|
|
54
61
|
// classic version which initially throws promise for Suspense
|
|
55
62
|
// but if we get a promise second time, we return the last signal and wait for promise to resolve
|
|
56
|
-
export function useSubClassic (signal, params, { async = false } = {}) {
|
|
63
|
+
export function useSubClassic (signal, params, { async = false, batch = false } = {}) {
|
|
57
64
|
const id = executionContextTracker.newHookId()
|
|
58
65
|
const cache = useCache()
|
|
59
66
|
const activePromiseRef = useRef()
|
|
60
67
|
const scheduleUpdate = useScheduleUpdate()
|
|
68
|
+
if (batch) promiseBatcher.activate()
|
|
61
69
|
const promiseOrSignal = params != null ? sub(signal, params) : sub(signal)
|
|
62
70
|
// 1. if it's a promise, throw it so that Suspense can catch it and wait for subscription to finish
|
|
63
71
|
if (promiseOrSignal.then) {
|
|
64
72
|
const promise = maybeThrottle(promiseOrSignal)
|
|
73
|
+
if (batch) {
|
|
74
|
+
promiseBatcher.add(promise)
|
|
75
|
+
if (async) scheduleUpdate(promise)
|
|
76
|
+
if (cache.has(id)) return cache.get(id)
|
|
77
|
+
return
|
|
78
|
+
}
|
|
65
79
|
// first time we just throw the promise to be caught by Suspense
|
|
66
80
|
if (!cache.has(id)) {
|
|
67
81
|
// if we are in async mode, we just return nothing and let the user
|
|
@@ -52,7 +52,7 @@ export default function wrapIntoSuspense ({
|
|
|
52
52
|
// to avoid updating during the subscribe/render phase
|
|
53
53
|
if (adm.hasPendingUpdate) {
|
|
54
54
|
adm.hasPendingUpdate = false
|
|
55
|
-
queueMicrotask(() => adm.onStoreChange())
|
|
55
|
+
queueMicrotask(() => adm.onStoreChange?.())
|
|
56
56
|
}
|
|
57
57
|
return () => destroyAdm(adm)
|
|
58
58
|
},
|
package/utils/setDiffDeep.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import isPlainObject from 'lodash/isPlainObject.js'
|
|
2
2
|
|
|
3
|
+
function isReactLike (value) {
|
|
4
|
+
return !!(value && typeof value === 'object' && typeof value.$$typeof === 'symbol')
|
|
5
|
+
}
|
|
6
|
+
|
|
3
7
|
export default function setDiffDeep (existing, updated) {
|
|
4
8
|
// Handle primitive types, null, and type mismatches
|
|
5
9
|
if (existing === null || updated === null ||
|
|
@@ -21,6 +25,9 @@ export default function setDiffDeep (existing, updated) {
|
|
|
21
25
|
return existing
|
|
22
26
|
}
|
|
23
27
|
|
|
28
|
+
// React elements are plain objects but must be treated as non-plain
|
|
29
|
+
if (isReactLike(updated)) return updated
|
|
30
|
+
|
|
24
31
|
// Handle non-plain objects - just return them as-is to fully overwrite
|
|
25
32
|
// and don't try to update an old object in-place
|
|
26
33
|
if (!isPlainObject(updated)) return updated
|
package/orm/Compat/REF.md
DELETED
|
@@ -1,315 +0,0 @@
|
|
|
1
|
-
# SignalCompat `ref` / `removeRef` — Compatibility Draft
|
|
2
|
-
|
|
3
|
-
This document captures a **draft** implementation of StartupJS/Racer-style `ref` behavior for Teamplay’s `SignalCompat`.
|
|
4
|
-
|
|
5
|
-
It is **not active in code** right now. The goal is to discuss and decide whether we want to bring it back, and in what form.
|
|
6
|
-
|
|
7
|
-
---
|
|
8
|
-
|
|
9
|
-
## 1) Why we need this
|
|
10
|
-
|
|
11
|
-
In LMS there are a few **real usages** of model refs (not React DOM refs):
|
|
12
|
-
|
|
13
|
-
- `components/Media/index.js`
|
|
14
|
-
```js
|
|
15
|
-
if ($fullscreen) $localFullscreen.ref($fullscreen)
|
|
16
|
-
```
|
|
17
|
-
- `main/components/FilterV2/index.js`
|
|
18
|
-
```js
|
|
19
|
-
if (!isMultiSelect) $localValue.ref($value)
|
|
20
|
-
```
|
|
21
|
-
- `main/Layout/Tutoring/index.js` and `v5/apps/main/Layout/Tutoring/index.js`
|
|
22
|
-
```js
|
|
23
|
-
$session.ref('tutoringSession', $tutoringSession)
|
|
24
|
-
$session.removeRef('tutoringSession')
|
|
25
|
-
```
|
|
26
|
-
|
|
27
|
-
These use the **Racer model ref**, which effectively makes one path behave like another path (alias). Teamplay doesn’t have this concept, so we explored a minimal compat layer.
|
|
28
|
-
|
|
29
|
-
---
|
|
30
|
-
|
|
31
|
-
## 2) Target API (minimal subset)
|
|
32
|
-
|
|
33
|
-
We only target what LMS actually uses:
|
|
34
|
-
|
|
35
|
-
### `ref(target)`
|
|
36
|
-
|
|
37
|
-
```js
|
|
38
|
-
$local.ref($.users.user1)
|
|
39
|
-
```
|
|
40
|
-
|
|
41
|
-
This means `$local` mirrors `$users.user1` and mutating `$local` mutates `$users.user1`.
|
|
42
|
-
|
|
43
|
-
### `ref(subpath, target)`
|
|
44
|
-
|
|
45
|
-
```js
|
|
46
|
-
$session.ref('tutoringSession', $tutoringSession)
|
|
47
|
-
```
|
|
48
|
-
|
|
49
|
-
This means `$session.tutoringSession` acts as an alias to `$tutoringSession`.
|
|
50
|
-
|
|
51
|
-
### `removeRef(path?)`
|
|
52
|
-
|
|
53
|
-
```js
|
|
54
|
-
$local.removeRef()
|
|
55
|
-
$session.removeRef('tutoringSession')
|
|
56
|
-
```
|
|
57
|
-
|
|
58
|
-
Stops syncing.
|
|
59
|
-
|
|
60
|
-
---
|
|
61
|
-
|
|
62
|
-
## 3) Semantics vs Racer
|
|
63
|
-
|
|
64
|
-
Racer refs are deep and complicated (they respond to all model events, including array insert/remove/move, etc).
|
|
65
|
-
|
|
66
|
-
This draft **only covers**:
|
|
67
|
-
- Signal-level aliasing (one signal proxies another).
|
|
68
|
-
- No `refList`, `refExtra`, `refMap`.
|
|
69
|
-
- No automatic path-patching for list inserts/moves.
|
|
70
|
-
|
|
71
|
-
It should be enough for current LMS usages.
|
|
72
|
-
|
|
73
|
-
---
|
|
74
|
-
|
|
75
|
-
## 4) Draft Implementation Strategy
|
|
76
|
-
|
|
77
|
-
### 4.1 Keep a ref store on root
|
|
78
|
-
|
|
79
|
-
We store refs on root signal:
|
|
80
|
-
|
|
81
|
-
```js
|
|
82
|
-
const REFS = Symbol('compat refs')
|
|
83
|
-
$root[REFS] = new Map()
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
Each entry is keyed by `fromPath` and stores `{ stop }` cleanup.
|
|
87
|
-
|
|
88
|
-
### 4.2 One-way reactive sync (target → alias)
|
|
89
|
-
|
|
90
|
-
We use `@nx-js/observer-util` `observe()` to track target changes and push them into alias:
|
|
91
|
-
|
|
92
|
-
```js
|
|
93
|
-
const toReaction = observe(() => {
|
|
94
|
-
const value = $to.get()
|
|
95
|
-
trackDeep(value)
|
|
96
|
-
setDiffDeepBypassRef($from, deepCopy(value))
|
|
97
|
-
}, { lazy: true })
|
|
98
|
-
|
|
99
|
-
toReaction()
|
|
100
|
-
```
|
|
101
|
-
|
|
102
|
-
Why deep copy?
|
|
103
|
-
- Without it, `setDiffDeep` can re-use same object references and skip updates.
|
|
104
|
-
- Deep copy ensures the diffing path detects change.
|
|
105
|
-
|
|
106
|
-
### 4.3 Forward all mutations from alias → target
|
|
107
|
-
|
|
108
|
-
To avoid two reactions and feedback loops, we forward all mutator calls:
|
|
109
|
-
|
|
110
|
-
- `set`, `setNull`, `setDiffDeep`, `setEach`
|
|
111
|
-
- `del`
|
|
112
|
-
- `increment`
|
|
113
|
-
- `push`, `unshift`, `insert`, `pop`, `shift`, `remove`, `move`
|
|
114
|
-
- `stringInsert`, `stringRemove`
|
|
115
|
-
- `assign`
|
|
116
|
-
|
|
117
|
-
Forwarding uses a hidden `REF_TARGET` symbol on the alias signal.
|
|
118
|
-
|
|
119
|
-
### 4.4 Mutator forward mechanism
|
|
120
|
-
|
|
121
|
-
On each mutator:
|
|
122
|
-
|
|
123
|
-
```js
|
|
124
|
-
const forwarded = forwardRef(this, 'set', arguments)
|
|
125
|
-
if (forwarded) return forwarded
|
|
126
|
-
```
|
|
127
|
-
|
|
128
|
-
`forwardRef()` resolves to a target signal if present and applies the same method there.
|
|
129
|
-
|
|
130
|
-
---
|
|
131
|
-
|
|
132
|
-
## 5) Draft Code (for later restoration)
|
|
133
|
-
|
|
134
|
-
Below is the exact code we removed from `SignalCompat.js`. It can be re-applied as-is.
|
|
135
|
-
|
|
136
|
-
### 5.1 Imports (add back)
|
|
137
|
-
|
|
138
|
-
```js
|
|
139
|
-
import { raw, observe, unobserve } from '@nx-js/observer-util'
|
|
140
|
-
```
|
|
141
|
-
|
|
142
|
-
### 5.2 Symbols and helpers (add near other helpers)
|
|
143
|
-
|
|
144
|
-
```js
|
|
145
|
-
const REFS = Symbol('compat refs')
|
|
146
|
-
const REF_TARGET = Symbol('compat ref target')
|
|
147
|
-
|
|
148
|
-
function getRefStore ($signal) {
|
|
149
|
-
const $root = getRoot($signal) || $signal
|
|
150
|
-
$root[REFS] ??= new Map()
|
|
151
|
-
return $root[REFS]
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
function createRefLink ($from, $to) {
|
|
155
|
-
const toReaction = observe(() => {
|
|
156
|
-
const value = $to.get()
|
|
157
|
-
trackDeep(value)
|
|
158
|
-
setDiffDeepBypassRef($from, deepCopy(value))
|
|
159
|
-
}, { lazy: true })
|
|
160
|
-
|
|
161
|
-
// Prime sync and start tracking.
|
|
162
|
-
toReaction()
|
|
163
|
-
return () => {
|
|
164
|
-
unobserve(toReaction)
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function trackDeep (value, seen = new Set()) {
|
|
169
|
-
if (!value || typeof value !== 'object') return
|
|
170
|
-
if (seen.has(value)) return
|
|
171
|
-
seen.add(value)
|
|
172
|
-
if (Array.isArray(value)) {
|
|
173
|
-
for (const item of value) trackDeep(item, seen)
|
|
174
|
-
} else {
|
|
175
|
-
for (const key in value) {
|
|
176
|
-
if (Object.prototype.hasOwnProperty.call(value, key)) {
|
|
177
|
-
trackDeep(value[key], seen)
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
function resolveRefSignal ($signal) {
|
|
184
|
-
let current = $signal
|
|
185
|
-
const seen = new Set()
|
|
186
|
-
while (current && current[REF_TARGET]) {
|
|
187
|
-
if (seen.has(current)) break
|
|
188
|
-
seen.add(current)
|
|
189
|
-
current = current[REF_TARGET]
|
|
190
|
-
}
|
|
191
|
-
return current
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
function forwardRef ($signal, methodName, args) {
|
|
195
|
-
const $target = resolveRefSignal($signal)
|
|
196
|
-
if ($target === $signal) return null
|
|
197
|
-
return SignalCompat.prototype[methodName].apply($target, args)
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
function setDiffDeepBypassRef ($signal, value) {
|
|
201
|
-
return Signal.prototype.set.call($signal, value)
|
|
202
|
-
}
|
|
203
|
-
```
|
|
204
|
-
|
|
205
|
-
### 5.3 `ref()` / `removeRef()` methods (add to `SignalCompat`)
|
|
206
|
-
|
|
207
|
-
```js
|
|
208
|
-
ref (path, target, options) {
|
|
209
|
-
if (arguments.length > 3) throw Error('Signal.ref() expects one to three arguments')
|
|
210
|
-
let $from = this
|
|
211
|
-
let $to
|
|
212
|
-
if (arguments.length === 1) {
|
|
213
|
-
$to = resolveRefTarget(this, path, 'Signal.ref()')
|
|
214
|
-
} else if (arguments.length === 2) {
|
|
215
|
-
if (isSignalLike(target) || typeof target === 'string') {
|
|
216
|
-
const segments = parseAtSubpath(path, 1, 'Signal.ref()')
|
|
217
|
-
$from = resolveSignal(this, segments)
|
|
218
|
-
$to = resolveRefTarget(this, target, 'Signal.ref()')
|
|
219
|
-
} else {
|
|
220
|
-
$to = resolveRefTarget(this, path, 'Signal.ref()')
|
|
221
|
-
options = target
|
|
222
|
-
}
|
|
223
|
-
} else {
|
|
224
|
-
const segments = parseAtSubpath(path, 1, 'Signal.ref()')
|
|
225
|
-
$from = resolveSignal(this, segments)
|
|
226
|
-
$to = resolveRefTarget(this, target, 'Signal.ref()')
|
|
227
|
-
}
|
|
228
|
-
if (!$to) throw Error('Signal.ref() expects a target path or signal')
|
|
229
|
-
if ($from === $to) return $from
|
|
230
|
-
const store = getRefStore($from)
|
|
231
|
-
const fromPath = $from.path()
|
|
232
|
-
const existing = store.get(fromPath)
|
|
233
|
-
if (existing) existing.stop()
|
|
234
|
-
const stop = createRefLink($from, $to, options)
|
|
235
|
-
store.set(fromPath, { stop })
|
|
236
|
-
$from[REF_TARGET] = $to
|
|
237
|
-
return $from
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
removeRef (path) {
|
|
241
|
-
if (arguments.length > 1) throw Error('Signal.removeRef() expects a single argument')
|
|
242
|
-
let $from = this
|
|
243
|
-
if (arguments.length === 1) {
|
|
244
|
-
const segments = parseAtSubpath(path, 1, 'Signal.removeRef()')
|
|
245
|
-
$from = resolveSignal(this, segments)
|
|
246
|
-
}
|
|
247
|
-
const store = getRefStore($from)
|
|
248
|
-
const fromPath = $from.path()
|
|
249
|
-
const existing = store.get(fromPath)
|
|
250
|
-
if (existing) {
|
|
251
|
-
existing.stop()
|
|
252
|
-
store.delete(fromPath)
|
|
253
|
-
}
|
|
254
|
-
if ($from[REF_TARGET]) delete $from[REF_TARGET]
|
|
255
|
-
}
|
|
256
|
-
```
|
|
257
|
-
|
|
258
|
-
### 5.4 Forwarding mutations (add to each mutator)
|
|
259
|
-
|
|
260
|
-
Example for `set()`:
|
|
261
|
-
|
|
262
|
-
```js
|
|
263
|
-
async set (path, value) {
|
|
264
|
-
const forwarded = forwardRef(this, 'set', arguments)
|
|
265
|
-
if (forwarded) return forwarded
|
|
266
|
-
// ...existing body
|
|
267
|
-
}
|
|
268
|
-
```
|
|
269
|
-
|
|
270
|
-
Same pattern for:
|
|
271
|
-
- `setNull`, `setDiffDeep`, `setEach`
|
|
272
|
-
- `del`
|
|
273
|
-
- `increment`
|
|
274
|
-
- `push`, `unshift`, `insert`, `pop`, `shift`, `remove`, `move`
|
|
275
|
-
- `stringInsert`, `stringRemove`
|
|
276
|
-
- `assign`
|
|
277
|
-
|
|
278
|
-
### 5.5 Supporting helpers (only needed with ref)
|
|
279
|
-
|
|
280
|
-
```js
|
|
281
|
-
function isSignalLike (value) {
|
|
282
|
-
return value && typeof value.path === 'function' && typeof value.get === 'function'
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
function resolveRefTarget ($signal, target, methodName) {
|
|
286
|
-
if (isSignalLike(target)) return target
|
|
287
|
-
if (typeof target === 'string') {
|
|
288
|
-
const segments = parseAtSubpath(target, 1, methodName)
|
|
289
|
-
const $root = getRoot($signal) || $signal
|
|
290
|
-
return resolveSignal($root, segments)
|
|
291
|
-
}
|
|
292
|
-
return undefined
|
|
293
|
-
}
|
|
294
|
-
```
|
|
295
|
-
|
|
296
|
-
---
|
|
297
|
-
|
|
298
|
-
## 6) Draft tests (removed)
|
|
299
|
-
|
|
300
|
-
We also had tests in `packages/teamplay/test/signalCompat.js`. They can be restored if needed:
|
|
301
|
-
|
|
302
|
-
- `syncs values both ways for direct signals`
|
|
303
|
-
- `supports subpath refs from root`
|
|
304
|
-
- `removeRef stops syncing`
|
|
305
|
-
|
|
306
|
-
---
|
|
307
|
-
|
|
308
|
-
## 7) Risks and limitations
|
|
309
|
-
|
|
310
|
-
- This is **not a full racer ref** implementation.
|
|
311
|
-
- No support for `refList`, `refExtra`, `refMap`.
|
|
312
|
-
- No array index patching when list changes.
|
|
313
|
-
- Might not handle exotic cases with cyclic refs.
|
|
314
|
-
|
|
315
|
-
That said, it’s deliberately scoped to known LMS usage patterns and should be “good enough” for those.
|