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.
- package/orm/Compat/README.md +4 -1
- package/orm/Compat/SignalCompat.js +23 -4
- package/orm/Compat/modelEvents.js +48 -1
- package/package.json +2 -2
- package/react/renderAttemptDestroyer.js +24 -15
- package/react/trapRender.js +5 -2
- package/react/useSub.js +3 -2
- package/react/useSuspendMemo.js +2 -2
package/orm/Compat/README.md
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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": "
|
|
86
|
+
"gitHead": "633e39dc754332bcd3d95ddc4cfb3522f0bfd694"
|
|
87
87
|
}
|
|
@@ -1,37 +1,46 @@
|
|
|
1
1
|
class RenderAttemptDestroyer {
|
|
2
2
|
constructor () {
|
|
3
3
|
this.fns = []
|
|
4
|
-
this.
|
|
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.
|
|
11
|
+
if (compat) this.compatAttemptCleanupArmed = true
|
|
11
12
|
}
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
this.
|
|
14
|
+
armCompatAttemptCleanup () {
|
|
15
|
+
this.compatAttemptCleanupArmed = true
|
|
15
16
|
}
|
|
16
17
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
return undefined
|
|
21
|
-
}
|
|
18
|
+
armSuspenseGate () {
|
|
19
|
+
this.suspenseGateArmed = true
|
|
20
|
+
}
|
|
22
21
|
|
|
23
|
-
|
|
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
|
|
26
|
-
|
|
27
|
-
|
|
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.
|
|
42
|
+
this.compatAttemptCleanupArmed = false
|
|
43
|
+
this.suspenseGateArmed = false
|
|
35
44
|
}
|
|
36
45
|
}
|
|
37
46
|
|
package/react/trapRender.js
CHANGED
|
@@ -24,8 +24,11 @@ export default function trapRender ({ render, cache, destroy, componentId }) {
|
|
|
24
24
|
destroyed = true
|
|
25
25
|
throw err
|
|
26
26
|
}
|
|
27
|
-
const
|
|
28
|
-
|
|
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
|
-
//
|
|
162
|
-
|
|
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
|
}
|
package/react/useSuspendMemo.js
CHANGED
|
@@ -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.
|
|
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.
|
|
50
|
+
renderAttemptDestroyer.armSuspenseGate()
|
|
51
51
|
throw promise
|
|
52
52
|
}
|
|
53
53
|
}
|