teamplay 0.2.5 → 0.3.0

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/index.js CHANGED
@@ -14,7 +14,7 @@ export { GLOBAL_ROOT_ID } from './orm/Root.js'
14
14
  export const $ = _getRootSignal({ rootId: GLOBAL_ROOT_ID, rootFunction: universal$ })
15
15
  export default $
16
16
  export { default as sub } from './orm/sub.js'
17
- export { default as useSub } from './react/useSub.js'
17
+ export { default as useSub, setUseDeferredValue as __setUseDeferredValue } from './react/useSub.js'
18
18
  export { default as observer } from './react/observer.js'
19
19
  export { connection, setConnection, getConnection, fetchOnly, setFetchOnly, publicOnly, setPublicOnly } from './orm/connection.js'
20
20
  export { GUID_PATTERN, hasMany, hasOne, hasManyFlags, belongsTo, pickFormFields } from '@teamplay/schema'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teamplay",
3
- "version": "0.2.5",
3
+ "version": "0.3.0",
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.2.5",
27
- "@teamplay/cache": "^0.2.5",
28
- "@teamplay/channel": "^0.2.5",
29
- "@teamplay/debug": "^0.2.5",
30
- "@teamplay/schema": "^0.2.5",
31
- "@teamplay/utils": "^0.2.5",
26
+ "@teamplay/backend": "^0.3.0",
27
+ "@teamplay/cache": "^0.3.0",
28
+ "@teamplay/channel": "^0.3.0",
29
+ "@teamplay/debug": "^0.3.0",
30
+ "@teamplay/schema": "^0.3.0",
31
+ "@teamplay/utils": "^0.3.0",
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": "e4ea7de3c12214a24928af14b882651b3aeb5599"
66
+ "gitHead": "1cc50389c3f1aa7b75d727cb6f0b1f61627a6b22"
67
67
  }
@@ -1,13 +1,10 @@
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 { forwardRef as _forwardRef, useRef, useSyncExternalStore } from 'react'
1
+ import { forwardRef as _forwardRef, useRef } from 'react'
5
2
  import { observe, unobserve } from '@nx-js/observer-util'
6
3
  import _throttle from 'lodash/throttle.js'
7
4
  import { createCaches, getDummyCache } from '@teamplay/cache'
8
5
  import { __increment, __decrement } from '@teamplay/debug'
9
6
  import executionContextTracker from './executionContextTracker.js'
10
- import { pipeComponentMeta, useUnmount, useId } from './helpers.js'
7
+ import { pipeComponentMeta, useUnmount, useId, useTriggerUpdate } from './helpers.js'
11
8
  import trapRender from './trapRender.js'
12
9
 
13
10
  const DEFAULT_THROTTLE_TIMEOUT = 100
@@ -28,30 +25,7 @@ export default function convertToObserver (BaseComponent, {
28
25
  let Component = (...args) => {
29
26
  const [cache, destroyCache] = useCreateCacheRef(enableCache)
30
27
  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)
28
+ const triggerUpdate = useTriggerUpdate()
55
29
 
56
30
  // wrap the BaseComponent into an observe decorator once.
57
31
  // This way it will track any observable changes and will trigger rerender
@@ -61,7 +35,7 @@ export default function convertToObserver (BaseComponent, {
61
35
  let update = () => {
62
36
  // It's important to block updates caused by rendering itself
63
37
  // (when the sync rendering is in progress).
64
- if (!executionContextTracker.isActive()) adm.onStoreChange?.()
38
+ if (!executionContextTracker.isActive()) triggerUpdate()
65
39
  }
66
40
  if (throttle) update = _throttle(update, throttle)
67
41
  destroyRef.current = (where) => {
package/react/helpers.js CHANGED
@@ -23,8 +23,21 @@ export function pipeComponentMeta (SourceComponent, TargetComponent, suffix = ''
23
23
  }
24
24
 
25
25
  export function useId () {
26
- const { componentId } = useContext(ComponentMetaContext)
27
- return componentId
26
+ const context = useContext(ComponentMetaContext)
27
+ if (!context) throw Error(ERRORS.useId)
28
+ return context.componentId
29
+ }
30
+
31
+ export function useTriggerUpdate () {
32
+ const context = useContext(ComponentMetaContext)
33
+ if (!context) throw Error(ERRORS.useTriggerUpdate)
34
+ return context.triggerUpdate
35
+ }
36
+
37
+ export function useScheduleUpdate () {
38
+ const context = useContext(ComponentMetaContext)
39
+ if (!context) throw Error(ERRORS.useScheduleUpdate)
40
+ return context.scheduleUpdate
28
41
  }
29
42
 
30
43
  export function useUnmount (fn) {
@@ -37,3 +50,18 @@ export function useUnmount (fn) {
37
50
  []
38
51
  )
39
52
  }
53
+
54
+ const ERRORS = {
55
+ useTriggerUpdate: `
56
+ useTriggerUpdate() can only be used inside a component wrapped with observer().
57
+ You have probably forgot to wrap your component with observer().
58
+ `,
59
+ useScheduleUpdate: `
60
+ useScheduleUpdate() can only be used inside a component wrapped with observer().
61
+ You have probably forgot to wrap your component with observer().
62
+ `,
63
+ useId: `
64
+ useId() can only be used inside a component wrapped with observer().
65
+ You have probably forgot to wrap your component with observer().
66
+ `
67
+ }
package/react/useSub.js CHANGED
@@ -1,14 +1,27 @@
1
1
  import { useRef, useDeferredValue } from 'react'
2
2
  import sub from '../orm/sub.js'
3
+ import { useScheduleUpdate } from './helpers.js'
3
4
 
4
5
  let TEST_THROTTLING = false
5
6
 
6
- // version of sub() which works as a react hook and throws promise for Suspense
7
+ // experimental feature to leverage useDeferredValue() to handle re-subscriptions.
8
+ // Currently it does lead to issues with extra rerenders and requires further investigation
9
+ let USE_DEFERRED_VALUE = false
10
+
7
11
  export default function useSub (signal, params) {
12
+ if (USE_DEFERRED_VALUE) {
13
+ return useSubDeferred(signal, params) // eslint-disable-line react-hooks/rules-of-hooks
14
+ } else {
15
+ return useSubClassic(signal, params) // eslint-disable-line react-hooks/rules-of-hooks
16
+ }
17
+ }
18
+
19
+ // version of sub() which works as a react hook and throws promise for Suspense
20
+ export function useSubDeferred (signal, params) {
8
21
  signal = useDeferredValue(signal)
9
22
  params = useDeferredValue(params ? JSON.stringify(params) : undefined)
10
- params = params ? JSON.parse(params) : undefined
11
- const promiseOrSignal = params ? sub(signal, params) : sub(signal)
23
+ params = params != null ? JSON.parse(params) : undefined
24
+ const promiseOrSignal = params != null ? sub(signal, params) : sub(signal)
12
25
  // 1. if it's a promise, throw it so that Suspense can catch it and wait for subscription to finish
13
26
  if (promiseOrSignal.then) {
14
27
  if (TEST_THROTTLING) {
@@ -27,6 +40,40 @@ export default function useSub (signal, params) {
27
40
  return promiseOrSignal
28
41
  }
29
42
 
43
+ // classic version which initially throws promise for Suspense
44
+ // but if we get a promise second time, we return the last signal and wait for promise to resolve
45
+ export function useSubClassic (signal, params) {
46
+ const $signalRef = useRef()
47
+ const activePromiseRef = useRef()
48
+ const scheduleUpdate = useScheduleUpdate()
49
+ const promiseOrSignal = params != null ? sub(signal, params) : sub(signal)
50
+ // 1. if it's a promise, throw it so that Suspense can catch it and wait for subscription to finish
51
+ if (promiseOrSignal.then) {
52
+ let promise
53
+ if (TEST_THROTTLING) {
54
+ // simulate slow network
55
+ promise = new Promise((resolve, reject) => {
56
+ setTimeout(() => {
57
+ promiseOrSignal.then(resolve, reject)
58
+ }, TEST_THROTTLING)
59
+ })
60
+ } else {
61
+ promise = promiseOrSignal
62
+ }
63
+ // first time we just throw the promise to be caught by Suspense
64
+ if (!$signalRef.current) throw promise
65
+ // if we already have a previous signal, we return it and wait for new promise to resolve
66
+ scheduleUpdate(promise)
67
+ return $signalRef.current
68
+ }
69
+ // 2. if it's a signal, we save it into ref to make sure it's not garbage collected while component exists
70
+ if ($signalRef.current !== promiseOrSignal) {
71
+ activePromiseRef.current = undefined
72
+ $signalRef.current = promiseOrSignal
73
+ }
74
+ return promiseOrSignal
75
+ }
76
+
30
77
  export function setTestThrottling (ms) {
31
78
  if (typeof ms !== 'number') throw Error('setTestThrottling() accepts only a number in ms')
32
79
  if (ms === 0) throw Error('setTestThrottling(0) is not allowed, use resetTestThrottling() instead')
@@ -36,3 +83,6 @@ export function setTestThrottling (ms) {
36
83
  export function resetTestThrottling () {
37
84
  TEST_THROTTLING = false
38
85
  }
86
+ export function setUseDeferredValue (value) {
87
+ USE_DEFERRED_VALUE = value
88
+ }
@@ -1,4 +1,6 @@
1
- import { forwardRef as _forwardRef, memo, createElement as el, Suspense, useId, useRef } from 'react'
1
+ // useSyncExternalStore is used to trigger an update same as in MobX
2
+ // ref: https://github.com/mobxjs/mobx/blob/94bc4997c14152ff5aefcaac64d982d5c21ba51a/packages/mobx-react-lite/src/useObserver.ts
3
+ import { useSyncExternalStore, forwardRef as _forwardRef, memo, createElement as el, Suspense, useId, useRef } from 'react'
2
4
  import { pipeComponentMeta, pipeComponentDisplayName, ComponentMetaContext } from './helpers.js'
3
5
 
4
6
  export default function wrapIntoSuspense ({
@@ -11,10 +13,49 @@ export default function wrapIntoSuspense ({
11
13
  let SuspenseWrapper = (props, ref) => {
12
14
  const componentId = useId()
13
15
  const componentMetaRef = useRef()
16
+ const admRef = useRef()
17
+ if (!admRef.current) {
18
+ const adm = {
19
+ stateVersion: Symbol(), // eslint-disable-line symbol-description
20
+ onStoreChange: undefined,
21
+ scheduledUpdatePromise: undefined,
22
+ subscribe (onStoreChange) {
23
+ adm.onStoreChange = () => {
24
+ adm.stateVersion = Symbol() // eslint-disable-line symbol-description
25
+ onStoreChange()
26
+ }
27
+ adm.scheduleUpdate = promise => {
28
+ if (!promise?.then) throw Error('scheduleUpdate() expects a promise')
29
+ if (adm.scheduledUpdatePromise === promise) return
30
+ adm.scheduledUpdatePromise = promise
31
+ promise.then(() => {
32
+ if (adm.scheduledUpdatePromise !== promise) return
33
+ adm.scheduledUpdatePromise = undefined
34
+ adm.onStoreChange?.()
35
+ })
36
+ }
37
+ return () => {
38
+ adm.onStoreChange = undefined
39
+ adm.scheduledUpdatePromise = undefined
40
+ adm.scheduleUpdate = undefined
41
+ }
42
+ },
43
+ getSnapshot () {
44
+ return adm.stateVersion
45
+ }
46
+ }
47
+ admRef.current = adm
48
+ }
49
+ const adm = admRef.current
50
+
51
+ useSyncExternalStore(adm.subscribe, adm.getSnapshot, adm.getSnapshot)
52
+
14
53
  if (!componentMetaRef.current) {
15
54
  componentMetaRef.current = {
16
55
  componentId,
17
- createdAt: Date.now()
56
+ createdAt: Date.now(),
57
+ triggerUpdate: () => adm.onStoreChange?.(),
58
+ scheduleUpdate: promise => adm.scheduleUpdate?.(promise)
18
59
  }
19
60
  }
20
61