teamplay 0.0.1
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/README.md +95 -0
- package/connect/index.js +12 -0
- package/connect/sharedbConnection.cjs +3 -0
- package/connect/test.js +12 -0
- package/index.js +44 -0
- package/orm/$.js +38 -0
- package/orm/Cache.js +29 -0
- package/orm/Doc.js +232 -0
- package/orm/Query.js +278 -0
- package/orm/Reaction.js +44 -0
- package/orm/Root.js +41 -0
- package/orm/Signal.js +211 -0
- package/orm/Value.js +27 -0
- package/orm/addModel.js +31 -0
- package/orm/connection.js +26 -0
- package/orm/dataTree.js +196 -0
- package/orm/getSignal.js +100 -0
- package/orm/sub.js +32 -0
- package/package.json +60 -0
- package/react/convertToObserver.js +93 -0
- package/react/executionContextTracker.js +32 -0
- package/react/helpers.js +35 -0
- package/react/observer.js +9 -0
- package/react/trapRender.js +45 -0
- package/react/universal$.js +9 -0
- package/react/universalSub.js +9 -0
- package/react/wrapIntoSuspense.js +63 -0
- package/schema/GUID_PATTERN.js +1 -0
- package/schema/associations.js +51 -0
- package/schema/pickFormFields.js +104 -0
- package/server.js +12 -0
- package/utils/uuid.cjs +2 -0
package/orm/dataTree.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { observable, raw } from '@nx-js/observer-util'
|
|
2
|
+
import jsonDiff from 'json0-ot-diff'
|
|
3
|
+
import diffMatchPatch from 'diff-match-patch'
|
|
4
|
+
import { getConnection } from './connection.js'
|
|
5
|
+
|
|
6
|
+
const ALLOW_PARTIAL_DOC_CREATION = false
|
|
7
|
+
|
|
8
|
+
export const dataTreeRaw = {}
|
|
9
|
+
const dataTree = observable(dataTreeRaw)
|
|
10
|
+
|
|
11
|
+
export function get (segments, tree = dataTree) {
|
|
12
|
+
let dataNode = tree
|
|
13
|
+
for (const segment of segments) {
|
|
14
|
+
if (dataNode == null) return dataNode
|
|
15
|
+
dataNode = dataNode[segment]
|
|
16
|
+
}
|
|
17
|
+
return dataNode
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getRaw (segments) {
|
|
21
|
+
return get(segments, dataTreeRaw)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function set (segments, value, tree = dataTree) {
|
|
25
|
+
let dataNode = tree
|
|
26
|
+
let dataNodeRaw = raw(dataTree)
|
|
27
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
28
|
+
const segment = segments[i]
|
|
29
|
+
if (dataNode[segment] == null) {
|
|
30
|
+
// if next segment is a number, it means that we are in the array
|
|
31
|
+
if (typeof segments[i + 1] === 'number') dataNode[segment] = []
|
|
32
|
+
else dataNode[segment] = {}
|
|
33
|
+
}
|
|
34
|
+
dataNode = dataNode[segment]
|
|
35
|
+
dataNodeRaw = dataNodeRaw[segment]
|
|
36
|
+
}
|
|
37
|
+
const key = segments[segments.length - 1]
|
|
38
|
+
// handle adding out of bounds empty element to the array
|
|
39
|
+
if (value == null && Array.isArray(dataNodeRaw) && key >= dataNodeRaw.length) {
|
|
40
|
+
// inject new undefined elements to the end of the array
|
|
41
|
+
dataNode.splice(dataNodeRaw.length, key - dataNodeRaw.length + 1,
|
|
42
|
+
...Array(key - dataNodeRaw.length + 1).fill(undefined))
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
// handle when the value didn't change
|
|
46
|
+
if (value === dataNodeRaw[key]) return
|
|
47
|
+
// handle setting undefined value
|
|
48
|
+
if (value == null) {
|
|
49
|
+
if (Array.isArray(dataNodeRaw)) {
|
|
50
|
+
// if parent is an array -- we set array element to undefined
|
|
51
|
+
// IMPORTANT: JSON serialization will replace `undefined` with `null`
|
|
52
|
+
// so if the data will go to the server, it will be serialized as `null`.
|
|
53
|
+
// And when it comes back from the server it will be still `null`.
|
|
54
|
+
// This can lead to confusion since when you set `undefined` the value
|
|
55
|
+
// might end up becoming `null` for seemingly no reason (like in this case).
|
|
56
|
+
dataNode[key] = undefined
|
|
57
|
+
} else {
|
|
58
|
+
// if parent is an object -- we completely delete the property.
|
|
59
|
+
// Deleting the property is better for the JSON serialization
|
|
60
|
+
// since JSON does not have `undefined` values and replaces them with `null`.
|
|
61
|
+
delete dataNode[key]
|
|
62
|
+
}
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
// just set the new value
|
|
66
|
+
dataNode[key] = value
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function del (segments, tree = dataTree) {
|
|
70
|
+
let dataNode = tree
|
|
71
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
72
|
+
const segment = segments[i]
|
|
73
|
+
if (dataNode[segment] == null) return
|
|
74
|
+
dataNode = dataNode[segment]
|
|
75
|
+
}
|
|
76
|
+
if (Array.isArray(dataNode)) {
|
|
77
|
+
// remove the element from the array
|
|
78
|
+
dataNode.splice(segments[segments.length - 1], 1)
|
|
79
|
+
} else {
|
|
80
|
+
// remove the property from the object
|
|
81
|
+
delete dataNode[segments[segments.length - 1]]
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function setPublicDoc (segments, value, deleteValue = false) {
|
|
86
|
+
if (segments.length === 0) throw Error(ERRORS.publicDoc(segments))
|
|
87
|
+
if (segments.length === 1) {
|
|
88
|
+
// set multiple documents at the same time
|
|
89
|
+
if (typeof value !== 'object') throw Error(ERRORS.notObjectCollection(segments, value))
|
|
90
|
+
for (const docId in value) {
|
|
91
|
+
await setPublicDoc([segments[0], docId], value[docId])
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const [collection, docId] = segments
|
|
95
|
+
if (typeof docId === 'number') throw Error(ERRORS.publicDocIdNumber(segments))
|
|
96
|
+
if (docId === 'undefined') throw Error(ERRORS.publicDocIdUndefined(segments))
|
|
97
|
+
if (!(collection && docId)) throw Error(ERRORS.publicDoc(segments))
|
|
98
|
+
const doc = getConnection().get(collection, docId)
|
|
99
|
+
if (!doc.data && deleteValue) throw Error(ERRORS.deleteNonExistentDoc(segments))
|
|
100
|
+
// make sure that the value is not observable to not trigger extra reads. And clone it
|
|
101
|
+
value = raw(value)
|
|
102
|
+
if (value == null) {
|
|
103
|
+
value = undefined
|
|
104
|
+
} else {
|
|
105
|
+
value = JSON.parse(JSON.stringify(value))
|
|
106
|
+
}
|
|
107
|
+
if (segments.length === 2 && !doc.data) {
|
|
108
|
+
// > create a new doc. Full doc data is provided
|
|
109
|
+
if (typeof value !== 'object') throw Error(ERRORS.notObject(segments, value))
|
|
110
|
+
const newDoc = value
|
|
111
|
+
return new Promise((resolve, reject) => {
|
|
112
|
+
doc.create(newDoc, err => err ? reject(err) : resolve())
|
|
113
|
+
})
|
|
114
|
+
} else if (!doc.data) {
|
|
115
|
+
// >> create a new doc. Partial doc data is provided (subpath)
|
|
116
|
+
// NOTE: We throw an error when trying to set a subpath on a non-existing doc
|
|
117
|
+
// to prevent potential mistakes. In future we might allow it though.
|
|
118
|
+
if (!ALLOW_PARTIAL_DOC_CREATION) throw Error(ERRORS.partialDocCreation(segments, value))
|
|
119
|
+
const newDoc = {}
|
|
120
|
+
set(segments.slice(2), value, newDoc)
|
|
121
|
+
return new Promise((resolve, reject) => {
|
|
122
|
+
doc.create(newDoc, err => err ? reject(err) : resolve())
|
|
123
|
+
})
|
|
124
|
+
} else if (segments.length === 2 && (deleteValue || value == null)) {
|
|
125
|
+
// > delete doc
|
|
126
|
+
return new Promise((resolve, reject) => {
|
|
127
|
+
doc.del(err => err ? reject(err) : resolve())
|
|
128
|
+
})
|
|
129
|
+
} else if (segments.length === 2) {
|
|
130
|
+
// > modify existing doc. Full doc modification
|
|
131
|
+
if (typeof value !== 'object') throw Error(ERRORS.notObject(segments, value))
|
|
132
|
+
const oldDoc = getRaw([collection, docId])
|
|
133
|
+
const diff = jsonDiff(oldDoc, value, diffMatchPatch)
|
|
134
|
+
return new Promise((resolve, reject) => {
|
|
135
|
+
doc.submitOp(diff, err => err ? reject(err) : resolve())
|
|
136
|
+
})
|
|
137
|
+
} else {
|
|
138
|
+
// > modify existing doc. Partial doc modification
|
|
139
|
+
const oldDoc = getRaw([collection, docId])
|
|
140
|
+
const newDoc = JSON.parse(JSON.stringify(oldDoc))
|
|
141
|
+
if (deleteValue) {
|
|
142
|
+
del(segments.slice(2), newDoc)
|
|
143
|
+
} else {
|
|
144
|
+
set(segments.slice(2), value, newDoc)
|
|
145
|
+
}
|
|
146
|
+
const diff = jsonDiff(oldDoc, newDoc, diffMatchPatch)
|
|
147
|
+
return new Promise((resolve, reject) => {
|
|
148
|
+
doc.submitOp(diff, err => err ? reject(err) : resolve())
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export default dataTree
|
|
154
|
+
|
|
155
|
+
const ERRORS = {
|
|
156
|
+
publicDoc: segments => `Public doc should have collection and docId. Got: ${segments}`,
|
|
157
|
+
nonExistingDoc: segments => `
|
|
158
|
+
Trying to modify a non-existing doc ${segments}.
|
|
159
|
+
Make sure you have subscribed to the doc before modifying it OR creating it.
|
|
160
|
+
`,
|
|
161
|
+
notObject: (segments, value) => `
|
|
162
|
+
Trying to set a non-object value to a public doc ${segments}.
|
|
163
|
+
Value: ${value}
|
|
164
|
+
`,
|
|
165
|
+
notObjectCollection: (segments, value) => `
|
|
166
|
+
Trying to set multiple documents for the collection but the value passed is not an object.
|
|
167
|
+
Path: ${segments}
|
|
168
|
+
Value: ${value}
|
|
169
|
+
`,
|
|
170
|
+
publicDocIdNumber: segments => `
|
|
171
|
+
Public doc id must be a string. Got a number: ${segments}
|
|
172
|
+
`,
|
|
173
|
+
deleteNonExistentDoc: segments => `
|
|
174
|
+
Trying to delete data from a non-existing doc ${segments}.
|
|
175
|
+
Make sure that the document exists and you are subscribed to it
|
|
176
|
+
before trying to delete anything from it or the doc itself.
|
|
177
|
+
`,
|
|
178
|
+
publicDocIdUndefined: segments => `
|
|
179
|
+
Trying to modify a public document with the id 'undefined'.
|
|
180
|
+
It's most likely a bug in your code and the variable you are using to store
|
|
181
|
+
the document id is not initialized correctly.
|
|
182
|
+
Got path: ${segments}
|
|
183
|
+
`,
|
|
184
|
+
partialDocCreation: (segments, value) => `
|
|
185
|
+
Can't set a value to a subpath of a document which doesn't exist.
|
|
186
|
+
|
|
187
|
+
You have probably forgotten to subscribe to the document.
|
|
188
|
+
You MUST subscribe to an existing document with 'sub()' before trying to modify it.
|
|
189
|
+
|
|
190
|
+
If instead you want to create a new document, you must provide the full data for it
|
|
191
|
+
and set it for the $.collection.docId signal.
|
|
192
|
+
|
|
193
|
+
Path: ${segments}
|
|
194
|
+
Value: ${value}
|
|
195
|
+
`
|
|
196
|
+
}
|
package/orm/getSignal.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import Cache from './Cache.js'
|
|
2
|
+
import Signal, { regularBindings, extremelyLateBindings, isPublicCollection, isPrivateCollection } from './Signal.js'
|
|
3
|
+
import { findModel } from './addModel.js'
|
|
4
|
+
import { LOCAL } from './$.js'
|
|
5
|
+
import { ROOT, ROOT_ID, GLOBAL_ROOT_ID } from './Root.js'
|
|
6
|
+
import { QUERIES } from './Query.js'
|
|
7
|
+
|
|
8
|
+
const PROXIES_CACHE = new Cache()
|
|
9
|
+
const PROXY_TO_SIGNAL = new WeakMap()
|
|
10
|
+
|
|
11
|
+
// extremely late bindings let you use fields in your raw data which have the same name as signal's methods
|
|
12
|
+
const USE_EXTREMELY_LATE_BINDINGS = true
|
|
13
|
+
|
|
14
|
+
// get proxy-wrapped signal from cache or create a new one
|
|
15
|
+
// TODO: move Private, Public, Local signals out of this file, same as Query has its own signal
|
|
16
|
+
export default function getSignal ($root, segments = [], {
|
|
17
|
+
useExtremelyLateBindings = USE_EXTREMELY_LATE_BINDINGS,
|
|
18
|
+
rootId,
|
|
19
|
+
signalHash,
|
|
20
|
+
proxyHandlers = getDefaultProxyHandlers({ useExtremelyLateBindings })
|
|
21
|
+
} = {}) {
|
|
22
|
+
if (!($root instanceof Signal)) {
|
|
23
|
+
if (segments.length === 0 && !rootId) throw Error(ERRORS.rootIdRequired)
|
|
24
|
+
if (segments.length >= 1 && isPrivateCollection(segments[0])) {
|
|
25
|
+
if (segments[0] === QUERIES) {
|
|
26
|
+
// TODO: this is a hack to temporarily let the queries work.
|
|
27
|
+
// '$queries' collection is always added to the global (singleton) root signal.
|
|
28
|
+
// In future it should also be part of the particular root signal.
|
|
29
|
+
$root = getSignal(undefined, [], { rootId: GLOBAL_ROOT_ID })
|
|
30
|
+
} else {
|
|
31
|
+
throw Error(ERRORS.rootSignalRequired)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
signalHash ??= hashSegments(segments, $root?.[ROOT_ID] || rootId)
|
|
36
|
+
let proxy = PROXIES_CACHE.get(signalHash)
|
|
37
|
+
if (proxy) return proxy
|
|
38
|
+
|
|
39
|
+
const SignalClass = getSignalClass(segments)
|
|
40
|
+
const signal = new SignalClass(segments)
|
|
41
|
+
proxy = new Proxy(signal, proxyHandlers)
|
|
42
|
+
if (segments.length >= 1) {
|
|
43
|
+
if (isPrivateCollection(segments[0])) {
|
|
44
|
+
proxy[ROOT] = $root
|
|
45
|
+
} else {
|
|
46
|
+
// TODO: this is probably a hack, currently public collection signals don't need a root signal
|
|
47
|
+
// but without it calling the methods of root signal like $.get() doesn't work
|
|
48
|
+
proxy[ROOT] = $root || getSignal(undefined, [], { rootId: GLOBAL_ROOT_ID })
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
PROXY_TO_SIGNAL.set(proxy, signal)
|
|
52
|
+
const dependencies = []
|
|
53
|
+
|
|
54
|
+
// if the signal is a child of the local value created through the $() function,
|
|
55
|
+
// we need to add the parent signal ('$local.id') to the dependencies so that it doesn't get garbage collected
|
|
56
|
+
// before the child signal ('$local.id.firstName') is garbage collected.
|
|
57
|
+
// Same goes for public collections -- we need to keep the document signal alive while its child signals are alive
|
|
58
|
+
if (segments.length > 2) {
|
|
59
|
+
if (segments[0] === LOCAL) {
|
|
60
|
+
dependencies.push(getSignal($root, segments.slice(0, 2)))
|
|
61
|
+
} else if (isPublicCollection(segments[0])) {
|
|
62
|
+
dependencies.push(getSignal(undefined, segments.slice(0, 2)))
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
PROXIES_CACHE.set(signalHash, proxy, dependencies)
|
|
67
|
+
return proxy
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function getDefaultProxyHandlers ({ useExtremelyLateBindings } = {}) {
|
|
71
|
+
return useExtremelyLateBindings ? extremelyLateBindings : regularBindings
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function hashSegments (segments, rootId) {
|
|
75
|
+
if (segments.length === 0) {
|
|
76
|
+
if (!rootId) throw Error(ERRORS.rootIdRequired)
|
|
77
|
+
return JSON.stringify({ root: rootId })
|
|
78
|
+
} else if (isPrivateCollection(segments[0])) {
|
|
79
|
+
if (!rootId) throw Error(ERRORS.privateCollectionRootIdRequired(segments))
|
|
80
|
+
return JSON.stringify({ private: [rootId, segments] })
|
|
81
|
+
} else {
|
|
82
|
+
return JSON.stringify(segments)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function getSignalClass (segments) {
|
|
87
|
+
return findModel(segments) ?? Signal
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function rawSignal (proxy) {
|
|
91
|
+
return PROXY_TO_SIGNAL.get(proxy)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export { PROXIES_CACHE as __DEBUG_SIGNALS_CACHE__ }
|
|
95
|
+
|
|
96
|
+
const ERRORS = {
|
|
97
|
+
rootIdRequired: 'Root signal must have a rootId specified',
|
|
98
|
+
privateCollectionRootIdRequired: segments => `Private collection signal must have a rootId specified. Segments: ${segments}`,
|
|
99
|
+
rootSignalRequired: 'First argument of getSignal() for private collections must be a Root Signal'
|
|
100
|
+
}
|
package/orm/sub.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import Signal, { SEGMENTS, isPublicCollectionSignal, isPublicDocumentSignal } from './Signal.js'
|
|
2
|
+
import { docSubscriptions } from './Doc.js'
|
|
3
|
+
import { querySubscriptions, getQuerySignal } from './Query.js'
|
|
4
|
+
|
|
5
|
+
export default function sub ($signal, params) {
|
|
6
|
+
if (isPublicDocumentSignal($signal)) {
|
|
7
|
+
return doc$($signal)
|
|
8
|
+
} else if (isPublicCollectionSignal($signal)) {
|
|
9
|
+
return query$($signal, params)
|
|
10
|
+
} else if (typeof $signal === 'function' && !($signal instanceof Signal)) {
|
|
11
|
+
return api$($signal, params)
|
|
12
|
+
} else {
|
|
13
|
+
throw Error('Invalid args passed for sub()')
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function doc$ ($doc) {
|
|
18
|
+
const promise = docSubscriptions.subscribe($doc)
|
|
19
|
+
if (!promise) return $doc
|
|
20
|
+
return new Promise(resolve => promise.then(() => resolve($doc)))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function query$ ($collection, params) {
|
|
24
|
+
const $query = getQuerySignal($collection[SEGMENTS], params)
|
|
25
|
+
const promise = querySubscriptions.subscribe($query)
|
|
26
|
+
if (!promise) return $query
|
|
27
|
+
return new Promise(resolve => promise.then(() => resolve($query)))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function api$ (fn, args) {
|
|
31
|
+
throw Error('sub() for async functions is not implemented yet')
|
|
32
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "teamplay",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Deep signals ORM for React with real-time collaboration",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.js",
|
|
9
|
+
"./connect": "./connect/index.js",
|
|
10
|
+
"./server": "./server.js",
|
|
11
|
+
"./connect-test": "./connect/test.js"
|
|
12
|
+
},
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"test": "npm run test-server && npm run test-client",
|
|
18
|
+
"test-server": "node --expose-gc -r ./test/_init.cjs --test",
|
|
19
|
+
"test-server-only": "node --expose-gc -r ./test/_init.cjs --test --test-only",
|
|
20
|
+
"test-client": "NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" jest"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@nx-js/observer-util": "^4.1.3",
|
|
24
|
+
"@startupjs/cache": "^0.56.0-alpha.0",
|
|
25
|
+
"@startupjs/channel": "^0.56.0-alpha.0",
|
|
26
|
+
"@startupjs/debug": "^0.56.0-alpha.0",
|
|
27
|
+
"@startupjs/utils": "^0.56.0-alpha.64",
|
|
28
|
+
"diff-match-patch": "^1.0.5",
|
|
29
|
+
"json0-ot-diff": "^1.1.2",
|
|
30
|
+
"lodash": "^4.17.20",
|
|
31
|
+
"sharedb": "^5.0.0",
|
|
32
|
+
"uuid": "^9.0.1"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@jest/globals": "^29.7.0",
|
|
36
|
+
"@testing-library/react": "^15.0.7",
|
|
37
|
+
"jest": "^29.7.0",
|
|
38
|
+
"jest-environment-jsdom": "^29.7.0",
|
|
39
|
+
"sharedb-mingo-memory": "^3.0.1"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"react": "*",
|
|
43
|
+
"sharedb-mingo-memory": "*"
|
|
44
|
+
},
|
|
45
|
+
"peerDependenciesMeta": {
|
|
46
|
+
"sharedb-mingo-memory": {
|
|
47
|
+
"optional": true
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"jest": {
|
|
51
|
+
"transform": {},
|
|
52
|
+
"testEnvironment": "jsdom",
|
|
53
|
+
"testRegex": "test_client/.*\\.jsx?$",
|
|
54
|
+
"testPathIgnorePatterns": [
|
|
55
|
+
"node_modules",
|
|
56
|
+
"<rootDir>/test_client/helpers"
|
|
57
|
+
]
|
|
58
|
+
},
|
|
59
|
+
"license": "MIT"
|
|
60
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// TODO: rewrite to use useSyncExternalStore like in mobx. This will also help with handling Suspense abandonment better
|
|
2
|
+
// to cleanup the observer() reaction when the component is unmounted or was abandoned and unmounts will never trigger.
|
|
3
|
+
// ref: https://github.com/mobxjs/mobx/blob/94bc4997c14152ff5aefcaac64d982d5c21ba51a/packages/mobx-react-lite/src/useObserver.ts
|
|
4
|
+
import { createElement as el, forwardRef as _forwardRef, useCallback, useState, useMemo } from 'react'
|
|
5
|
+
import _throttle from 'lodash/throttle.js'
|
|
6
|
+
import { observe, unobserve } from '@nx-js/observer-util'
|
|
7
|
+
import { pipeComponentMeta, useCache, useUnmount } from './helpers.js'
|
|
8
|
+
import trapRender from './trapRender.js'
|
|
9
|
+
|
|
10
|
+
const DEFAULT_THROTTLE_TIMEOUT = 100
|
|
11
|
+
|
|
12
|
+
export default function convertToObserver (BaseComponent, options = {}) {
|
|
13
|
+
options = { ...DEFAULT_OPTIONS, ...options }
|
|
14
|
+
const { forwardRef } = options
|
|
15
|
+
// MAGIC. This fixes hot-reloading. TODO: figure out WHY it fixes it
|
|
16
|
+
const random = Math.random()
|
|
17
|
+
|
|
18
|
+
// memo; we are not intested in deep updates
|
|
19
|
+
// in props; we assume that if deep objects are changed,
|
|
20
|
+
// this is in observables, which would have been tracked anyway
|
|
21
|
+
let Component = (...args) => {
|
|
22
|
+
// forceUpdate 2.0
|
|
23
|
+
const forceUpdate = useForceUpdate(options.throttle)
|
|
24
|
+
const cache = useCache(options.cache != null ? options.cache : true)
|
|
25
|
+
|
|
26
|
+
// wrap the BaseComponent into an observe decorator once.
|
|
27
|
+
// This way it will track any observable changes and will trigger rerender
|
|
28
|
+
const reactionRef = useMemo(() => ({}), [])
|
|
29
|
+
const observedRender = useMemo(() => {
|
|
30
|
+
const blockUpdate = { value: false }
|
|
31
|
+
const update = () => {
|
|
32
|
+
// TODO: Decide whether the check for unmount is needed here
|
|
33
|
+
// Force update unless update is blocked. It's important to block
|
|
34
|
+
// updates caused by rendering
|
|
35
|
+
// (when the sync rendening is in progress)
|
|
36
|
+
if (!blockUpdate.value) forceUpdate()
|
|
37
|
+
}
|
|
38
|
+
const trappedRender = trapRender({ render: BaseComponent, blockUpdate, cache, reactionRef })
|
|
39
|
+
return observe(trappedRender, {
|
|
40
|
+
scheduler: update,
|
|
41
|
+
lazy: true
|
|
42
|
+
})
|
|
43
|
+
}, [random])
|
|
44
|
+
|
|
45
|
+
if (reactionRef.current !== observedRender) reactionRef.current = observedRender
|
|
46
|
+
|
|
47
|
+
// clean up observer on unmount
|
|
48
|
+
useUnmount(() => {
|
|
49
|
+
// TODO: this does not execute the same amount of times as observe() does,
|
|
50
|
+
// probably because of throw's of the async hooks.
|
|
51
|
+
// So there probably are memory leaks here. Research this.
|
|
52
|
+
if (observedRender.current) {
|
|
53
|
+
unobserve(observedRender.current)
|
|
54
|
+
observedRender.current = undefined
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
return observedRender(...args)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (forwardRef) Component = _forwardRef(Component)
|
|
62
|
+
pipeComponentMeta(BaseComponent, Component)
|
|
63
|
+
|
|
64
|
+
Component.__observerOptions = options
|
|
65
|
+
|
|
66
|
+
return Component
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const DEFAULT_OPTIONS = {
|
|
70
|
+
forwardRef: false,
|
|
71
|
+
suspenseProps: {
|
|
72
|
+
fallback: el(NullComponent, null, null)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function NullComponent () {
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function useForceUpdate (throttle) {
|
|
81
|
+
const [, setTick] = useState()
|
|
82
|
+
if (throttle) {
|
|
83
|
+
const timeout = typeof (throttle) === 'number' ? +throttle : DEFAULT_THROTTLE_TIMEOUT
|
|
84
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
85
|
+
return useCallback(
|
|
86
|
+
_throttle(() => {
|
|
87
|
+
setTick(Math.random())
|
|
88
|
+
}, timeout)
|
|
89
|
+
, [])
|
|
90
|
+
} else {
|
|
91
|
+
return () => setTick(Math.random())
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export default new class ExecutionContextTracker {
|
|
2
|
+
#contextId
|
|
3
|
+
#hooksCounter
|
|
4
|
+
|
|
5
|
+
isActive () {
|
|
6
|
+
return this.#contextId !== undefined
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
getComponentId () {
|
|
10
|
+
return this.#contextId
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
newHookId () {
|
|
14
|
+
this.incrementHooksCounter()
|
|
15
|
+
const id = `_${this.#contextId}_${this.#hooksCounter}`
|
|
16
|
+
return id
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
incrementHooksCounter () {
|
|
20
|
+
if (!this.#contextId) return
|
|
21
|
+
this.#hooksCounter++
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
_start (contextId) {
|
|
25
|
+
this.#contextId = contextId
|
|
26
|
+
this.#hooksCounter = -1
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
_clear () {
|
|
30
|
+
this.#contextId = undefined
|
|
31
|
+
}
|
|
32
|
+
}()
|
package/react/helpers.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { useMemo, useContext, createContext } from 'react'
|
|
2
|
+
import { CACHE_ACTIVE, getDummyCache } from '@startupjs/cache'
|
|
3
|
+
import useIsomorphicLayoutEffect from '@startupjs/utils/useIsomorphicLayoutEffect'
|
|
4
|
+
|
|
5
|
+
export const ComponentMetaContext = createContext({})
|
|
6
|
+
|
|
7
|
+
export function pipeComponentDisplayName (SourceComponent, TargetComponent, suffix = '', defaultName = 'StartupjsWrapper') {
|
|
8
|
+
const displayName = SourceComponent.displayName || SourceComponent.name
|
|
9
|
+
|
|
10
|
+
if (!TargetComponent.displayName) {
|
|
11
|
+
TargetComponent.displayName = displayName ? (displayName + suffix) : defaultName
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function pipeComponentMeta (SourceComponent, TargetComponent, suffix = '', defaultName = 'StartupjsWrapper') {
|
|
16
|
+
pipeComponentDisplayName(SourceComponent, TargetComponent, suffix, defaultName)
|
|
17
|
+
|
|
18
|
+
if (!TargetComponent.propTypes && SourceComponent.propTypes) {
|
|
19
|
+
TargetComponent.propTypes = SourceComponent.propTypes
|
|
20
|
+
}
|
|
21
|
+
if (!TargetComponent.defaultProps && SourceComponent.defaultProps) {
|
|
22
|
+
TargetComponent.defaultProps = SourceComponent.defaultProps
|
|
23
|
+
}
|
|
24
|
+
return TargetComponent
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function useCache (active) {
|
|
28
|
+
if (!CACHE_ACTIVE.value || !active) return useMemo(getDummyCache, []) // eslint-disable-line react-hooks/rules-of-hooks
|
|
29
|
+
const { cache } = useContext(ComponentMetaContext) // eslint-disable-line react-hooks/rules-of-hooks
|
|
30
|
+
return cache
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function useUnmount (fn) {
|
|
34
|
+
useIsomorphicLayoutEffect(() => fn, [])
|
|
35
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import convertToObserver from './convertToObserver.js'
|
|
2
|
+
import wrapIntoSuspense from './wrapIntoSuspense.js'
|
|
3
|
+
|
|
4
|
+
function observer (Component, options) {
|
|
5
|
+
return wrapIntoSuspense(convertToObserver(Component, options))
|
|
6
|
+
}
|
|
7
|
+
observer.__wrapObserverMeta = wrapIntoSuspense
|
|
8
|
+
observer.__makeObserver = convertToObserver
|
|
9
|
+
export default observer
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// trap render function (functional component) to block observer updates and activate cache
|
|
2
|
+
// during synchronous rendering
|
|
3
|
+
import { useId } from 'react'
|
|
4
|
+
import { unobserve } from '@nx-js/observer-util'
|
|
5
|
+
import executionContextTracker from './executionContextTracker.js'
|
|
6
|
+
|
|
7
|
+
export default function trapRender ({ render, blockUpdate, cache, reactionRef }) {
|
|
8
|
+
return (...args) => {
|
|
9
|
+
const id = useId()
|
|
10
|
+
executionContextTracker._start(id)
|
|
11
|
+
blockUpdate.value = true
|
|
12
|
+
cache.activate()
|
|
13
|
+
try {
|
|
14
|
+
// destroyer.reset() // TODO: this one is for any destructuring logic which might be needed
|
|
15
|
+
// promiseBatcher.reset() // TODO: this is to support useBatch* hooks
|
|
16
|
+
const res = render(...args)
|
|
17
|
+
// if (promiseBatcher.isActive()) {
|
|
18
|
+
// throw Error('[react-sharedb] useBatch* hooks were used without a closing useBatch() call.')
|
|
19
|
+
// }
|
|
20
|
+
blockUpdate.value = false // TODO: might want to just put it into finally block
|
|
21
|
+
return res
|
|
22
|
+
} catch (err) {
|
|
23
|
+
// TODO: this might only be needed only if promise is thrown
|
|
24
|
+
// (check if useUnmount in convertToObserver is called if a regular error is thrown)
|
|
25
|
+
if (reactionRef.current) {
|
|
26
|
+
unobserve(reactionRef.current)
|
|
27
|
+
reactionRef.current = undefined
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (!err.then) throw err
|
|
31
|
+
// If the Promise was thrown, we catch it before Suspense does.
|
|
32
|
+
// And we run destructors for each hook previous to the one
|
|
33
|
+
// which did throw this Promise.
|
|
34
|
+
// We have to manually do it since the unmount logic is not working
|
|
35
|
+
// for components which were terminated by Suspense as a result of
|
|
36
|
+
// a promise being thrown.
|
|
37
|
+
// const destroy = destroyer.getDestructor()
|
|
38
|
+
// throw err.then(destroy)
|
|
39
|
+
throw err
|
|
40
|
+
} finally {
|
|
41
|
+
cache.deactivate()
|
|
42
|
+
executionContextTracker._clear()
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import $ from '../orm/$.js'
|
|
2
|
+
import executionContextTracker from './executionContextTracker.js'
|
|
3
|
+
|
|
4
|
+
// universal versions of $() which work as a plain function or as a react hook
|
|
5
|
+
export default function universal$ ($root, value) {
|
|
6
|
+
let id
|
|
7
|
+
if (executionContextTracker.isActive()) id = executionContextTracker.newHookId()
|
|
8
|
+
return $($root, value, id)
|
|
9
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import sub from '../orm/sub.js'
|
|
2
|
+
import executionContextTracker from './executionContextTracker.js'
|
|
3
|
+
|
|
4
|
+
// universal versions of sub() which work as a plain function or as a react hook
|
|
5
|
+
export default function universalSub (...args) {
|
|
6
|
+
const promiseOrSignal = sub(...args)
|
|
7
|
+
if (executionContextTracker.isActive() && promiseOrSignal.then) throw promiseOrSignal
|
|
8
|
+
return promiseOrSignal
|
|
9
|
+
}
|