teamplay 0.1.11 → 0.1.13

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/Doc.js CHANGED
@@ -80,7 +80,9 @@ class Doc {
80
80
  }
81
81
 
82
82
  async unsubscribe () {
83
- if (!this.subscribed) throw Error('trying to unsubscribe while not subscribed')
83
+ if (!this.subscribed) {
84
+ throw Error('trying to unsubscribe while not subscribed. Doc: ' + [this.collection, this.docId])
85
+ }
84
86
  this.subscribed = undefined
85
87
  // if we are still handling the subscription, just wait for it to finish and then unsubscribe
86
88
  if (this.subscribing) {
@@ -216,7 +218,8 @@ class DocSubscriptions {
216
218
  if (!doc) return
217
219
  this.subCount.delete(hash)
218
220
  this.initialized.delete(hash)
219
- await doc.unsubscribe()
221
+ // If the document was initialized as part of query and wasn't directly subscribed to, we should not unsubscribe from it.
222
+ if (doc.subscribed) await doc.unsubscribe()
220
223
  if (doc.subscribed) return // if we subscribed again while waiting for unsubscribe, we don't delete the doc
221
224
  this.docs.delete(hash)
222
225
  }
package/orm/Query.js CHANGED
@@ -87,7 +87,9 @@ class Query {
87
87
  }
88
88
 
89
89
  async unsubscribe () {
90
- if (!this.subscribed) throw Error('trying to unsubscribe while not subscribed')
90
+ if (!this.subscribed) {
91
+ throw Error('trying to unsubscribe while not subscribed. Query: ' + [this.collection, this.params])
92
+ }
91
93
  this.subscribed = undefined
92
94
  // if we are still handling the subscription, just wait for it to finish and then unsubscribe
93
95
  if (this.subscribing) {
@@ -227,7 +229,7 @@ class QuerySubscriptions {
227
229
  let count = this.subCount.get(hash) || 0
228
230
  count -= 1
229
231
  if (count < 0) {
230
- if (ERROR_ON_EXCESSIVE_UNSUBSCRIBES) throw ERRORS.notSubscribed($query)
232
+ if (ERROR_ON_EXCESSIVE_UNSUBSCRIBES) throw Error(ERRORS.notSubscribed($query))
231
233
  return
232
234
  }
233
235
  if (count > 0) {
@@ -275,5 +277,8 @@ export function getQuerySignal (segments, params, options) {
275
277
  }
276
278
 
277
279
  const ERRORS = {
278
- notSubscribed: $doc => Error('trying to unsubscribe when not subscribed. Doc: ' + $doc.path())
280
+ notSubscribed: $query => `
281
+ trying to unsubscribe when not subscribed. Query:
282
+ ${[$query[SEGMENTS], $query[PARAMS]]}
283
+ `
279
284
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teamplay",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "Full-stack signals ORM with multiplayer",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -23,12 +23,12 @@
23
23
  },
24
24
  "dependencies": {
25
25
  "@nx-js/observer-util": "^4.1.3",
26
- "@teamplay/backend": "^0.1.11",
27
- "@teamplay/cache": "^0.1.11",
28
- "@teamplay/channel": "^0.1.11",
29
- "@teamplay/debug": "^0.1.11",
30
- "@teamplay/schema": "^0.1.11",
31
- "@teamplay/utils": "^0.1.11",
26
+ "@teamplay/backend": "^0.1.13",
27
+ "@teamplay/cache": "^0.1.13",
28
+ "@teamplay/channel": "^0.1.13",
29
+ "@teamplay/debug": "^0.1.13",
30
+ "@teamplay/schema": "^0.1.13",
31
+ "@teamplay/utils": "^0.1.13",
32
32
  "diff-match-patch": "^1.0.5",
33
33
  "events": "^3.3.0",
34
34
  "json0-ot-diff": "^1.1.2",
@@ -63,5 +63,5 @@
63
63
  ]
64
64
  },
65
65
  "license": "MIT",
66
- "gitHead": "1fbdde069e50c6cee18d8bb71c43ed5b06367cac"
66
+ "gitHead": "1abacbe2dd704f77dfa20d061edf89490ac65cee"
67
67
  }
@@ -1,93 +1,126 @@
1
1
  // TODO: rewrite to use useSyncExternalStore like in mobx. This will also help with handling Suspense abandonment better
2
2
  // to cleanup the observer() reaction when the component is unmounted or was abandoned and unmounts will never trigger.
3
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'
4
+ import { forwardRef as _forwardRef, useRef, useSyncExternalStore } from 'react'
6
5
  import { observe, unobserve } from '@nx-js/observer-util'
7
- import { pipeComponentMeta, useCache, useUnmount } from './helpers.js'
6
+ import _throttle from 'lodash/throttle.js'
7
+ import { createCaches, getDummyCache } from '@teamplay/cache'
8
+ import { __increment, __decrement } from '@teamplay/debug'
9
+ import executionContextTracker from './executionContextTracker.js'
10
+ import { pipeComponentMeta, useUnmount, useId } from './helpers.js'
8
11
  import trapRender from './trapRender.js'
9
12
 
10
13
  const DEFAULT_THROTTLE_TIMEOUT = 100
11
14
 
12
- export default function convertToObserver (BaseComponent, options = {}) {
13
- options = { ...DEFAULT_OPTIONS, ...options }
14
- const { forwardRef } = options
15
+ export default function convertToObserver (BaseComponent, {
16
+ forwardRef,
17
+ cache: enableCache = true,
18
+ throttle,
19
+ ...options
20
+ } = {}) {
21
+ throttle = normalizeThrottle(throttle)
15
22
  // MAGIC. This fixes hot-reloading. TODO: figure out WHY it fixes it
16
- const random = Math.random()
23
+ // const random = Math.random()
17
24
 
18
25
  // memo; we are not intested in deep updates
19
26
  // in props; we assume that if deep objects are changed,
20
27
  // this is in observables, which would have been tracked anyway
21
28
  let Component = (...args) => {
22
- // forceUpdate 2.0
23
- const forceUpdate = useForceUpdate(options.throttle)
24
- const cache = useCache(options.cache != null ? options.cache : true)
29
+ const [cache, destroyCache] = useCreateCacheRef(enableCache)
30
+ const componentId = useId()
31
+
32
+ const admRef = useRef()
33
+ if (!admRef.current) {
34
+ const adm = {
35
+ stateVersion: Symbol(), // eslint-disable-line symbol-description
36
+ onStoreChange: undefined,
37
+ subscribe (onStoreChange) {
38
+ adm.onStoreChange = () => {
39
+ adm.stateVersion = Symbol() // eslint-disable-line symbol-description
40
+ onStoreChange()
41
+ }
42
+ return () => {
43
+ adm.onStoreChange = undefined
44
+ }
45
+ },
46
+ getSnapshot () {
47
+ return adm.stateVersion
48
+ }
49
+ }
50
+ admRef.current = adm
51
+ }
52
+ const adm = admRef.current
53
+
54
+ useSyncExternalStore(adm.subscribe, adm.getSnapshot, adm.getSnapshot)
25
55
 
26
56
  // wrap the BaseComponent into an observe decorator once.
27
57
  // 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()
58
+ const reactionRef = useRef()
59
+ const destroyRef = useRef()
60
+ if (!reactionRef.current) {
61
+ let update = () => {
62
+ // It's important to block updates caused by rendering itself
63
+ // (when the sync rendering is in progress).
64
+ if (!executionContextTracker.isActive()) adm.onStoreChange?.()
65
+ }
66
+ if (throttle) update = _throttle(update, throttle)
67
+ destroyRef.current = (where) => {
68
+ if (!reactionRef.current) throw Error(`NO REACTION REF - ${where}`)
69
+ unobserve(reactionRef.current)
70
+ reactionRef.current = undefined
71
+ destroyRef.current = undefined
72
+ destroyCache(where)
37
73
  }
38
- const trappedRender = trapRender({ render: BaseComponent, blockUpdate, cache, reactionRef })
39
- return observe(trappedRender, {
74
+ const trappedRender = trapRender({
75
+ render: BaseComponent,
76
+ cache,
77
+ destroy: destroyRef.current,
78
+ componentId
79
+ })
80
+ reactionRef.current = observe(trappedRender, {
40
81
  scheduler: update,
41
82
  lazy: true
42
83
  })
43
- }, [random])
44
-
45
- if (reactionRef.current !== observedRender) reactionRef.current = observedRender
84
+ }
46
85
 
47
86
  // clean up observer on unmount
48
87
  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
- }
88
+ destroyRef.current('useUnmount()')
56
89
  })
57
90
 
58
- return observedRender(...args)
91
+ return reactionRef.current(...args)
59
92
  }
60
93
 
61
94
  if (forwardRef) Component = _forwardRef(Component)
62
95
  pipeComponentMeta(BaseComponent, Component)
63
96
 
64
- Component.__observerOptions = options
65
-
66
- return Component
97
+ return { Component, forwardRef, ...options }
67
98
  }
68
99
 
69
- const DEFAULT_OPTIONS = {
70
- forwardRef: false,
71
- suspenseProps: {
72
- fallback: el(NullComponent, null, null)
100
+ function normalizeThrottle (throttle) {
101
+ if (typeof throttle === 'boolean') {
102
+ if (throttle) return DEFAULT_THROTTLE_TIMEOUT
103
+ else return undefined
73
104
  }
105
+ if (typeof throttle === 'number') return throttle
106
+ if (throttle == null) return undefined
107
+ throw Error('observer(): throttle can be either boolean or number (milliseconds)')
74
108
  }
75
109
 
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())
110
+ function useCreateCacheRef (enableCache) {
111
+ const cacheRef = useRef()
112
+ const destroyCacheRef = useRef()
113
+ if (!cacheRef.current) {
114
+ __increment('ObserverWrapper.cache')
115
+ const _createCaches = enableCache ? createCaches : getDummyCache
116
+ cacheRef.current = _createCaches(['styles', 'model'])
117
+ destroyCacheRef.current = (where) => {
118
+ if (!cacheRef.current) throw Error(`NO CACHE REF - ${where}`)
119
+ __decrement('ObserverWrapper.cache')
120
+ cacheRef.current.clear()
121
+ cacheRef.current = undefined
122
+ destroyCacheRef.current = undefined
123
+ }
92
124
  }
125
+ return [cacheRef.current, destroyCacheRef.current]
93
126
  }
package/react/helpers.js CHANGED
@@ -1,6 +1,4 @@
1
- import { useMemo, useContext, createContext } from 'react'
2
- import { CACHE_ACTIVE, getDummyCache } from '@teamplay/cache'
3
- import useIsomorphicLayoutEffect from '../utils/useIsomorphicLayoutEffect.js'
1
+ import { useContext, createContext, useRef, useEffect } from 'react'
4
2
 
5
3
  export const ComponentMetaContext = createContext({})
6
4
 
@@ -24,12 +22,18 @@ export function pipeComponentMeta (SourceComponent, TargetComponent, suffix = ''
24
22
  return TargetComponent
25
23
  }
26
24
 
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
25
+ export function useId () {
26
+ const { componentId } = useContext(ComponentMetaContext)
27
+ return componentId
31
28
  }
32
29
 
33
30
  export function useUnmount (fn) {
34
- useIsomorphicLayoutEffect(() => fn, [])
31
+ const fnRef = useRef()
32
+ if (fnRef.current !== fn) fnRef.current = fn
33
+ useEffect(
34
+ () => () => {
35
+ fnRef.current()
36
+ },
37
+ []
38
+ )
35
39
  }
@@ -1,15 +1,12 @@
1
1
  // trap render function (functional component) to block observer updates and activate cache
2
2
  // during synchronous rendering
3
- import { useId } from 'react'
4
- import { unobserve } from '@nx-js/observer-util'
5
3
  import executionContextTracker from './executionContextTracker.js'
6
4
 
7
- export default function trapRender ({ render, blockUpdate, cache, reactionRef }) {
5
+ export default function trapRender ({ render, cache, destroy, componentId }) {
8
6
  return (...args) => {
9
- const id = useId()
10
- executionContextTracker._start(id)
11
- blockUpdate.value = true
7
+ executionContextTracker._start(componentId)
12
8
  cache.activate()
9
+ let destroyed
13
10
  try {
14
11
  // destroyer.reset() // TODO: this one is for any destructuring logic which might be needed
15
12
  // promiseBatcher.reset() // TODO: this is to support useBatch* hooks
@@ -17,15 +14,12 @@ export default function trapRender ({ render, blockUpdate, cache, reactionRef })
17
14
  // if (promiseBatcher.isActive()) {
18
15
  // throw Error('[react-sharedb] useBatch* hooks were used without a closing useBatch() call.')
19
16
  // }
20
- blockUpdate.value = false // TODO: might want to just put it into finally block
21
17
  return res
22
18
  } catch (err) {
23
19
  // TODO: this might only be needed only if promise is thrown
24
20
  // (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
- }
21
+ destroy('trapRender.js')
22
+ destroyed = true
29
23
 
30
24
  if (!err.then) throw err
31
25
  // If the Promise was thrown, we catch it before Suspense does.
@@ -38,7 +32,7 @@ export default function trapRender ({ render, blockUpdate, cache, reactionRef })
38
32
  // throw err.then(destroy)
39
33
  throw err
40
34
  } finally {
41
- cache.deactivate()
35
+ if (!destroyed) cache.deactivate()
42
36
  executionContextTracker._clear()
43
37
  }
44
38
  }
@@ -1,9 +1,18 @@
1
+ import { useRef } from 'react'
1
2
  import $ from '../orm/$.js'
2
3
  import executionContextTracker from './executionContextTracker.js'
3
4
 
4
5
  // universal versions of $() which work as a plain function or as a react hook
5
6
  export default function universal$ ($root, value) {
6
- let id
7
- if (executionContextTracker.isActive()) id = executionContextTracker.newHookId()
8
- return $($root, value, id)
7
+ if (executionContextTracker.isActive()) {
8
+ // within react component
9
+ const id = executionContextTracker.newHookId()
10
+ const $signal = $($root, value, id)
11
+ // save signal into ref to make sure it's not garbage collected while component exists
12
+ const $signalRef = useRef() // eslint-disable-line react-hooks/rules-of-hooks
13
+ if ($signalRef.current !== $signal) $signalRef.current = $signal
14
+ return $signal
15
+ } else {
16
+ return $($root, value)
17
+ }
9
18
  }
@@ -1,9 +1,17 @@
1
+ import { useRef } from 'react'
1
2
  import sub from '../orm/sub.js'
2
3
  import executionContextTracker from './executionContextTracker.js'
3
4
 
4
5
  // universal versions of sub() which work as a plain function or as a react hook
5
6
  export default function universalSub (...args) {
6
7
  const promiseOrSignal = sub(...args)
7
- if (executionContextTracker.isActive() && promiseOrSignal.then) throw promiseOrSignal
8
+ if (executionContextTracker.isActive()) {
9
+ // within react component
10
+ // 1. if it's a promise, throw it so that Suspense can catch it and wait for subscription to finish
11
+ if (promiseOrSignal.then) throw promiseOrSignal
12
+ // 2. if it's a signal, we save it into ref to make sure it's not garbage collected while component exists
13
+ const $signalRef = useRef() // eslint-disable-line react-hooks/rules-of-hooks
14
+ if ($signalRef.current !== promiseOrSignal) $signalRef.current = promiseOrSignal
15
+ }
8
16
  return promiseOrSignal
9
17
  }
@@ -1,50 +1,29 @@
1
- import { useMemo, forwardRef as _forwardRef, memo, createElement as el, Suspense } from 'react'
2
- import { createCaches } from '@teamplay/cache'
3
- import { __increment, __decrement } from '@teamplay/debug'
4
- import { pipeComponentMeta, pipeComponentDisplayName, ComponentMetaContext, useUnmount } from './helpers.js'
5
-
6
- export default function wrapIntoSuspense (
7
- ObservedComponent
8
- ) {
9
- const { forwardRef, suspenseProps } = ObservedComponent.__observerOptions
10
- if (!(suspenseProps && suspenseProps.fallback)) {
11
- throw Error(
12
- '[observer()] You must pass at least ' +
13
- 'a fallback parameter to suspenseProps'
14
- )
15
- }
1
+ import { forwardRef as _forwardRef, memo, createElement as el, Suspense, useId, useRef } from 'react'
2
+ import { pipeComponentMeta, pipeComponentDisplayName, ComponentMetaContext } from './helpers.js'
3
+
4
+ export default function wrapIntoSuspense ({
5
+ Component,
6
+ forwardRef,
7
+ suspenseProps = DEFAULT_SUSPENSE_PROPS
8
+ } = {}) {
9
+ if (!suspenseProps?.fallback) throw Error(ERRORS.noFallback)
16
10
 
17
11
  let SuspenseWrapper = (props, ref) => {
18
- const cache = useMemo(() => {
19
- __increment('ObserverWrapper.cache')
20
- return createCaches(['styles', 'model'])
21
- }, [])
22
- // TODO: using useState instead of useMemo will keep this intact during Fast Refresh
23
- // Research if we can change it to use it.
24
- // const [componentMeta] = React.useState({
25
- // componentId: $root.id(),
26
- // createdAt: Date.now(),
27
- // cache
28
- // })
29
- const componentMeta = useMemo(function () {
30
- return {
31
- // componentId: $root.id(), // TODO: implement creating a unique component guid here (if it's needed anymore)
32
- createdAt: Date.now(),
33
- cache
12
+ const componentId = useId()
13
+ const componentMetaRef = useRef()
14
+ if (!componentMetaRef.current) {
15
+ componentMetaRef.current = {
16
+ componentId,
17
+ createdAt: Date.now()
34
18
  }
35
- }, [])
36
-
37
- useUnmount(() => {
38
- __decrement('ObserverWrapper.cache')
39
- cache.clear()
40
- })
19
+ }
41
20
 
42
21
  if (forwardRef) props = { ...props, ref }
43
22
 
44
23
  return (
45
- el(ComponentMetaContext.Provider, { value: componentMeta },
24
+ el(ComponentMetaContext.Provider, { value: componentMetaRef.current },
46
25
  el(Suspense, suspenseProps,
47
- el(ObservedComponent, props)
26
+ el(Component, props)
48
27
  )
49
28
  )
50
29
  )
@@ -52,12 +31,19 @@ export default function wrapIntoSuspense (
52
31
 
53
32
  // pipe only displayName because forwardRef render function
54
33
  // do not support propTypes or defaultProps
55
- pipeComponentDisplayName(ObservedComponent, SuspenseWrapper, 'StartupjsObserverWrapper')
34
+ pipeComponentDisplayName(Component, SuspenseWrapper, 'StartupjsObserverWrapper')
56
35
 
57
36
  if (forwardRef) SuspenseWrapper = _forwardRef(SuspenseWrapper)
58
37
  SuspenseWrapper = memo(SuspenseWrapper)
59
38
 
60
- pipeComponentMeta(ObservedComponent, SuspenseWrapper)
39
+ pipeComponentMeta(Component, SuspenseWrapper)
61
40
 
62
41
  return SuspenseWrapper
63
42
  }
43
+
44
+ const DEFAULT_SUSPENSE_PROPS = { fallback: el(NullComponent, null, null) }
45
+ function NullComponent () { return null }
46
+
47
+ const ERRORS = {
48
+ noFallback: '[observer()] You must pass at least a fallback parameter to suspenseProps'
49
+ }