theatre/packages/react/src/index.ts
2021-10-02 14:12:25 +02:00

265 lines
6.5 KiB
TypeScript

import type {IDerivation} from '@theatre/dataverse'
import {Box} from '@theatre/dataverse'
import {prism, val} from '@theatre/dataverse'
import {findIndex} from 'lodash-es'
import queueMicrotask from 'queue-microtask'
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react'
import {unstable_batchedUpdates} from 'react-dom'
type $IntentionalAny = any
type VoidFn = () => void
const logger = {log: console.log}
function useForceUpdate(debugLabel?: string) {
const [, setTick] = useState(0)
const update = useCallback(() => {
if (process.env.NODE_ENV !== 'production' && debugLabel)
logger.log(debugLabel, 'forceUpdate', {trace: new Error()})
setTick((tick) => tick + 1)
}, [])
return update
}
export function usePrism<T>(
fn: () => T,
deps: unknown[],
debugLabel?: string,
): T {
const fnAsCallback = useCallback(fn, deps)
const boxRef = useRef<Box<typeof fn>>(null as $IntentionalAny)
if (!boxRef.current) {
boxRef.current = new Box(fnAsCallback)
} else {
boxRef.current.set(fnAsCallback)
}
const derivation = useMemo(
() =>
prism(() => {
const fn = boxRef.current.derivation.getValue()
return fn()
}),
[],
)
return useDerivation(derivation, debugLabel)
}
export const useVal: typeof val = (p: $IntentionalAny, debugLabel?: string) => {
return usePrism(() => val(p), [p], debugLabel)
}
/**
* Each usePrism() call is assigned an `order`. Parents have a smaller
* order than their children, and so on.
*/
let lastOrder = 0
/**
* A sorted array of derivations that need to be refreshed. The derivations are sorted
* by their order, which means a parent derivation always gets priority to children
* and descendents. Ie. we refresh the derivations top to bottom.
*/
const queue: QueueItem[] = []
const setOfQueuedItems = new Set<QueueItem>()
type QueueItem = {
order: number
/**
* runUpdate() is the equivalent of a forceUpdate() call.
*/
runUpdate: VoidFn
debugLabel?: string
}
let microtaskIsQueued = false
const pushToQueue = (item: QueueItem) => {
_pushToQueue(item)
queueIfNeeded()
}
const _pushToQueue = (item: QueueItem) => {
if (setOfQueuedItems.has(item)) return
setOfQueuedItems.add(item)
if (queue.length === 0) {
queue.push(item)
} else {
const index = findIndex(
queue,
(existingItem) => existingItem.order >= item.order,
)
if (index === -1) {
queue.push(item)
} else {
const right = queue[index]
if (right.order > item.order) {
queue.splice(index, 0, item)
}
}
}
}
const removeFromQueue = (item: QueueItem) => {
if (!setOfQueuedItems.has(item)) return
setOfQueuedItems.delete(item)
const index = findIndex(queue, (o) => o === item)
queue.splice(index, 1)
}
function queueIfNeeded() {
if (!microtaskIsQueued) {
microtaskIsQueued = true
queueMicrotask(() => {
let i = 0
while (queue.length > 0) {
i++
if (i === 4) {
// react might be skipping updates, perhaps in concurrent mode.
//we can recheck the queue later
setTimeout(queueIfNeeded, 1)
break
}
unstable_batchedUpdates(() => {
for (const item of queue) {
item.runUpdate()
}
}, 1)
}
microtaskIsQueued = false
})
}
}
/**
* @remarks
* It looks like this new implementation of useDerivation() manages to:
* 1. Not over-calculate the derivations
* 2. Render derivation in ancestor -\> descendent order
* 3. Not set off React's concurrent mode alarms
*
* It works like an implementation of Dataverse's Ticker, except that it runs
* the side effects in an order where a component's derivation is guaranteed
* to run before any of its descendents' derivations.
*
* I'm happy with how little bookkeeping we ended up doing here.
*/
function useDerivation<T>(der: IDerivation<T>, debugLabel?: string): T {
const _forceUpdate = useForceUpdate(debugLabel)
const refs = useRef<{queueItem: QueueItem; unmounted: boolean}>(
undefined as $IntentionalAny,
)
if (!refs.current) {
lastOrder++
refs.current = {
queueItem: {
debugLabel,
order: lastOrder,
runUpdate: () => {
if (!refs.current.unmounted) {
_forceUpdate()
}
},
},
unmounted: false,
}
}
const queueUpdate = useCallback(() => {
pushToQueue(refs.current.queueItem)
}, [])
useLayoutEffect(() => {
const untap = der.changesWithoutValues().tap(() => {
queueUpdate()
})
if (lastValueRef.current !== der.getValue()) {
queueUpdate()
}
return untap
}, [der])
useLayoutEffect(() => {
return function onUnmount() {
refs.current.unmounted = true
removeFromQueue(refs.current.queueItem)
}
}, [])
const lastValueRef = useRef<T>(undefined as $IntentionalAny as T)
const queueItem = refs.current.queueItem
// we defer refreshing our derivation if:
const mustDefer =
// we are actually queued to refresh
setOfQueuedItems.has(queueItem) &&
// but it's not our turn yet
queue[0] !== refs.current.queueItem
if (!mustDefer) {
removeFromQueue(queueItem)
lastValueRef.current = der.getValue()
} else {
// if it's not our turn, we return the last cached value,
// which react will actually drop, because the microtask
// queue will make sure to forceUpdate() us before react
// flushes to DOM.
}
return lastValueRef.current
}
/**
* This makes sure the prism derivation remains hot as long as the
* component calling the hook is alive, but it does not
* return the value of the derivation, and it does not
* re-render the component if the value of the derivation changes.
*
* Use this hook if you plan to read a derivation in a
* useEffect() call, without the derivation causing your
* element to re-render.
*/
export function usePrismWithoutReRender<T>(
fn: () => T,
deps: unknown[],
): IDerivation<T> {
const derivation = useMemo(() => prism(fn), deps)
return useDerivationWithoutReRender(derivation)
}
/**
* This makes sure the derivation remains hot as long as the
* component calling the hook is alive, but it does not
* return the value of the derivation, and it does not
* re-render the component if the value of the derivation changes.
*/
export function useDerivationWithoutReRender<T>(
der: IDerivation<T>,
): IDerivation<T> {
useEffect(() => {
const untap = der.keepHot()
return () => {
untap()
}
}, [der])
return der
}