UNPKG

@sanity/visual-editing

Version:

[![npm stat](https://img.shields.io/npm/dm/@sanity/visual-editing.svg?style=flat-square)](https://npm-stat.com/charts.html?package=@sanity/visual-editing) [![npm version](https://img.shields.io/npm/v/@sanity/visual-editing.svg?style=flat-square)](https://

121 lines (109 loc) 4.75 kB
import {getPublishedId} from '@sanity/client/csm' import type {SanityDocument} from '@sanity/types' import {startTransition, useEffect, useState} from 'react' import {useEffectEvent} from 'use-effect-event' import {isEmptyActor} from '../optimistic/context' import type {OptimisticReducer, OptimisticReducerAction} from '../optimistic/types' import {useOptimisticActor} from './useOptimisticActor' export function useOptimistic<T, U = SanityDocument>( passthrough: T, reducer: OptimisticReducer<T, U> | Array<OptimisticReducer<T, U>>, ): T { const [pristine, setPristine] = useState(true) const [optimistic, setOptimistic] = useState<T>(passthrough) const [lastEvent, setLastEvent] = useState<OptimisticReducerAction<U> | null>(null) const [lastPassthrough, setLastPassthrough] = useState<T>(passthrough) const actor = useOptimisticActor() /** * This action is used in two `useEffect` hooks, it needs access to the provided `reducer`, * but doesn't want to cause re-renders if `reducer` changes identity. * The `useEffectEvent` hook ensures that the `reducer` value is never stale when used, and doesn't trigger setup and teardown of * `useEffect` deps to make it happen. */ const reduceStateFromAction = useEffectEvent( (action: OptimisticReducerAction<U>, prevState: T) => { const reducers = Array.isArray(reducer) ? reducer : [reducer] return reducers.reduce( (acc, reducer) => reducer(acc, { document: action.document, id: getPublishedId(action.id), originalId: action.id, type: action.type, }), prevState, ) }, ) /** * Records the last passthrough value when reducers ran in response to a rebased event. * This allows us to later know when reducers should run should the passthrough change. */ const updateLastPassthrough = useEffectEvent(() => setLastPassthrough(passthrough)) /** * Handle rebase events, which runs the provided reducers, * caches the event that was used to produce the new state, * and marks the state as non-pristine. */ useEffect(() => { // If the actor hasn't been set yet, we don't need to subscribe to mutations if (isEmptyActor(actor)) { return } /** * The pristine event fires much too soon, so the temporary workaround is that we greatly delay firing `setPristine(true)`, * and instead relying on re-running reducers with the last event whenever the passthrough changes, to preserve the optimistic state, * until we hopefully have eventual consistency on the passthrough. */ let pristineTimeout: ReturnType<typeof setTimeout> const rebasedSub = actor.on('rebased.local', (_event) => { const event = { // @todo You shall not cast document: _event.document as U, id: _event.id, originalId: getPublishedId(_event.id), // @todo This should eventually be emitted by the state machine type: 'mutate' as const, } setOptimistic((prevState) => reduceStateFromAction(event, prevState)) setLastEvent(event) updateLastPassthrough() setPristine(false) clearTimeout(pristineTimeout) }) const pristineSub = actor.on('pristine', () => { pristineTimeout = setTimeout(() => { // Marking it in a startTransition allows react to interrupt the resulting render, should a new rebase happen and we're back to dirty startTransition(() => setPristine(true)) }, 15000) }) return () => { rebasedSub.unsubscribe() pristineSub.unsubscribe() } }, [actor]) /** * If the passthrough changes, and we are in a dirty state, we rerun the reducers with the new passthrough but the previous event. * Marking it in a transition allows react to interrupt this render should a new action happen, or should we be back in a pristine state. */ useEffect(() => { if (pristine) { // if we are pristine, then we will passthrough anyway return undefined } if (!lastEvent) { // If we don't have a lastEvent when we are pristine, it's a fatal error throw new Error('No last event found when syncing passthrough') } if (lastPassthrough === passthrough) { // If the passthrough hasn't changed, then we don't need to rerun the reducers return undefined } // Marking it in a startTransition allows react to interrupt the resulting render, should a new rebase happen startTransition(() => { setOptimistic(reduceStateFromAction(lastEvent, passthrough)) setLastPassthrough(passthrough) }) }, [lastEvent, lastPassthrough, passthrough, pristine]) return pristine ? passthrough : optimistic }