Skip to content

Commit

Permalink
feat: implement useAsyncSub(). It returns undefined if there is no da…
Browse files Browse the repository at this point in the history
…ta yet.
  • Loading branch information
cray0000 committed Sep 11, 2024
1 parent 627be48 commit 4db1480
Show file tree
Hide file tree
Showing 4 changed files with 113 additions and 46 deletions.
2 changes: 1 addition & 1 deletion packages/teamplay/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export { GLOBAL_ROOT_ID } from './orm/Root.js'
export const $ = _getRootSignal({ rootId: GLOBAL_ROOT_ID, rootFunction: universal$ })
export default $
export { default as sub } from './orm/sub.js'
export { default as useSub, setUseDeferredValue as __setUseDeferredValue } from './react/useSub.js'
export { default as useSub, useAsyncSub, setUseDeferredValue as __setUseDeferredValue } from './react/useSub.js'
export { default as observer } from './react/observer.js'
export { connection, setConnection, getConnection, fetchOnly, setFetchOnly, publicOnly, setPublicOnly } from './orm/connection.js'
export { useId, useNow, useScheduleUpdate, useTriggerUpdate } from './react/helpers.js'
Expand Down
87 changes: 53 additions & 34 deletions packages/teamplay/react/useSub.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,70 +8,79 @@ let TEST_THROTTLING = false
// Currently it does lead to issues with extra rerenders and requires further investigation
let USE_DEFERRED_VALUE = false

export default function useSub (signal, params) {
export function useAsyncSub (signal, params, options) {
return useSub(signal, params, { ...options, async: true })
}

export default function useSub (signal, params, options) {
if (USE_DEFERRED_VALUE) {
return useSubDeferred(signal, params) // eslint-disable-line react-hooks/rules-of-hooks
return useSubDeferred(signal, params, options) // eslint-disable-line react-hooks/rules-of-hooks
} else {
return useSubClassic(signal, params) // eslint-disable-line react-hooks/rules-of-hooks
return useSubClassic(signal, params, options) // eslint-disable-line react-hooks/rules-of-hooks
}
}

// version of sub() which works as a react hook and throws promise for Suspense
export function useSubDeferred (signal, params) {
export function useSubDeferred (signal, params, { async = false } = {}) {
const $signalRef = useRef() // eslint-disable-line react-hooks/rules-of-hooks
const scheduleUpdate = useScheduleUpdate()
signal = useDeferredValue(signal)
params = useDeferredValue(params ? JSON.stringify(params) : undefined)
params = params != null ? JSON.parse(params) : undefined
const promiseOrSignal = params != null ? sub(signal, params) : sub(signal)
// 1. if it's a promise, throw it so that Suspense can catch it and wait for subscription to finish
if (promiseOrSignal.then) {
if (TEST_THROTTLING) {
// simulate slow network
throw new Promise((resolve, reject) => {
setTimeout(() => {
promiseOrSignal.then(resolve, reject)
}, TEST_THROTTLING)
})
const promise = maybeThrottle(promiseOrSignal)
if (async) {
scheduleUpdate(promise)
return
}
throw promiseOrSignal
}
throw promise
// 2. if it's a signal, we save it into ref to make sure it's not garbage collected while component exists
const $signalRef = useRef() // eslint-disable-line react-hooks/rules-of-hooks
if ($signalRef.current !== promiseOrSignal) $signalRef.current = promiseOrSignal
return promiseOrSignal
} else {
const $signal = promiseOrSignal
if ($signalRef.current !== $signal) $signalRef.current = $signal
return $signal
}
}

// classic version which initially throws promise for Suspense
// but if we get a promise second time, we return the last signal and wait for promise to resolve
export function useSubClassic (signal, params) {
export function useSubClassic (signal, params, { async = false } = {}) {
const $signalRef = useRef()
const activePromiseRef = useRef()
const scheduleUpdate = useScheduleUpdate()
const promiseOrSignal = params != null ? sub(signal, params) : sub(signal)
// 1. if it's a promise, throw it so that Suspense can catch it and wait for subscription to finish
if (promiseOrSignal.then) {
let promise
if (TEST_THROTTLING) {
// simulate slow network
promise = new Promise((resolve, reject) => {
setTimeout(() => {
promiseOrSignal.then(resolve, reject)
}, TEST_THROTTLING)
})
} else {
promise = promiseOrSignal
}
const promise = maybeThrottle(promiseOrSignal)
// first time we just throw the promise to be caught by Suspense
if (!$signalRef.current) throw promise
if (!$signalRef.current) {
// if we are in async mode, we just return nothing and let the user
// handle appearance of signal on their own.
// We manually schedule an update when promise resolves since we can't
// rely on Suspense in this case to automatically trigger component's re-render
if (async) {
scheduleUpdate(promise)
return
}
// in regular mode we throw the promise to be caught by Suspense
// this way we guarantee that the signal with all the data
// will always be there when component is rendered
throw promise
}
// if we already have a previous signal, we return it and wait for new promise to resolve
scheduleUpdate(promise)
return $signalRef.current
}
// 2. if it's a signal, we save it into ref to make sure it's not garbage collected while component exists
if ($signalRef.current !== promiseOrSignal) {
activePromiseRef.current = undefined
$signalRef.current = promiseOrSignal
} else {
const $signal = promiseOrSignal
if ($signalRef.current !== $signal) {
activePromiseRef.current = undefined
$signalRef.current = $signal
}
return $signal
}
return promiseOrSignal
}

export function setTestThrottling (ms) {
Expand All @@ -86,3 +95,13 @@ export function resetTestThrottling () {
export function setUseDeferredValue (value) {
USE_DEFERRED_VALUE = value
}

// throttle to simulate slow network
function maybeThrottle (promise) {
if (!TEST_THROTTLING) return promise
return new Promise((resolve, reject) => {
setTimeout(() => {
promise.then(resolve, reject)
}, TEST_THROTTLING)
})
}
20 changes: 10 additions & 10 deletions packages/teamplay/react/wrapIntoSuspense.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,21 +19,21 @@ export default function wrapIntoSuspense ({
stateVersion: Symbol(), // eslint-disable-line symbol-description
onStoreChange: undefined,
scheduledUpdatePromise: undefined,
scheduleUpdate: promise => {
if (!promise?.then) throw Error('scheduleUpdate() expects a promise')
if (adm.scheduledUpdatePromise === promise) return
adm.scheduledUpdatePromise = promise
promise.then(() => {
if (adm.scheduledUpdatePromise !== promise) return
adm.scheduledUpdatePromise = undefined
adm.onStoreChange?.()
})
},
subscribe (onStoreChange) {
adm.onStoreChange = () => {
adm.stateVersion = Symbol() // eslint-disable-line symbol-description
onStoreChange()
}
adm.scheduleUpdate = promise => {
if (!promise?.then) throw Error('scheduleUpdate() expects a promise')
if (adm.scheduledUpdatePromise === promise) return
adm.scheduledUpdatePromise = promise
promise.then(() => {
if (adm.scheduledUpdatePromise !== promise) return
adm.scheduledUpdatePromise = undefined
adm.onStoreChange?.()
})
}
return () => {
adm.onStoreChange = undefined
adm.scheduledUpdatePromise = undefined
Expand Down
50 changes: 49 additions & 1 deletion packages/teamplay/test_client/react.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createElement as el, Fragment } from 'react'
import { describe, it, afterEach, expect, beforeAll as before } from '@jest/globals'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { $, useSub, observer, sub } from '../index.js'
import { $, useSub, useAsyncSub, observer, sub } from '../index.js'
import { setTestThrottling, resetTestThrottling } from '../react/useSub.js'
import connect from '../connect/test.js'

Expand Down Expand Up @@ -378,6 +378,54 @@ describe('useSub() for subscribing to queries', () => {
})
})

describe('useAsyncSub()', () => {
it('initially returns undefined, handles query parameter changes, should NOT show Suspense', async () => {
// TODO: without sub() doing $jane.set({}) and then again $jane.set({}) will not work and should throw an error
// (right now it tries to execute const newDoc = JSON.parse(JSON.stringify(oldDoc)) and fails)
const $users = $.usersAsync
const $john = await sub($users._1)
const $jane = await sub($users._2)
$john.set({ name: 'John', status: 'active', createdAt: 1 })
$jane.set({ name: 'Jane', status: 'inactive', createdAt: 2 })
await wait()
setTestThrottling(100)
const throttledWait = () => wait(130)
let renders = 0
const Component = observer(() => {
renders++
const $status = $()
const $activeUsers = useAsyncSub($users, { status: $status.get(), $sort: { createdAt: 1 } })
if (!$activeUsers) return el('span', {}, 'Waiting for users to load...')
return fr(
el('span', {}, $activeUsers.map($user => $user.name.get()).join(',')),
el('button', { id: 'active', onClick: () => $status.set('active') }),
el('button', { id: 'inactive', onClick: () => $status.set('inactive') })
)
}, { suspenseProps: { fallback: el('span', {}, 'Loading...') } })
const { container } = render(el(Component))
expect(renders).toBe(1)
expect(container.textContent).toBe('Waiting for users to load...')

await throttledWait()
expect(renders).toBe(2)
expect(container.textContent).toBe('John,Jane')

fireEvent.click(container.querySelector('#active'))
expect(renders).toBe(3)
expect(container.textContent).toBe('John,Jane')
await wait()
expect(renders).toBe(3)
expect(container.textContent).toBe('John,Jane')
await throttledWait()
expect(renders).toBe(4)
expect(container.textContent).toBe('John')

await wait()
expect(renders).toBe(4)
resetTestThrottling()
})
})

function fr (...children) {
return el(Fragment, {}, ...children)
}
Expand Down

0 comments on commit 4db1480

Please sign in to comment.