about summary refs log tree commit diff
path: root/src/lib/hooks/useToggleMutationQueue.ts
blob: 28ae8614241c6b6c9fb001b4967f87fe8f06e6ed (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
import {useState, useRef, useEffect, useCallback} from 'react'

type Task<TServerState> = {
  isOn: boolean
  resolve: (serverState: TServerState) => void
  reject: (e: unknown) => void
}

type TaskQueue<TServerState> = {
  activeTask: Task<TServerState> | null
  queuedTask: Task<TServerState> | null
}

function AbortError() {
  const e = new Error()
  e.name = 'AbortError'
  return e
}

export function useToggleMutationQueue<TServerState>({
  initialState,
  runMutation,
  onSuccess,
}: {
  initialState: TServerState
  runMutation: (
    prevState: TServerState,
    nextIsOn: boolean,
  ) => Promise<TServerState>
  onSuccess: (finalState: TServerState) => void
}) {
  // We use the queue as a mutable object.
  // This is safe becuase it is not used for rendering.
  const [queue] = useState<TaskQueue<TServerState>>({
    activeTask: null,
    queuedTask: null,
  })

  async function processQueue() {
    if (queue.activeTask) {
      // There is another active processQueue call iterating over tasks.
      // It will handle any newly added tasks, so we should exit early.
      return
    }
    // To avoid relying on the rendered state, capture it once at the start.
    // From that point on, and until the queue is drained, we'll use the real server state.
    let confirmedState: TServerState = initialState
    try {
      while (queue.queuedTask) {
        const prevTask = queue.activeTask
        const nextTask = queue.queuedTask
        queue.activeTask = nextTask
        queue.queuedTask = null
        if (prevTask?.isOn === nextTask.isOn) {
          // Skip multiple requests to update to the same value in a row.
          prevTask.reject(new (AbortError as any)())
          continue
        }
        try {
          // The state received from the server feeds into the next task.
          // This lets us queue deletions of not-yet-created resources.
          confirmedState = await runMutation(confirmedState, nextTask.isOn)
          nextTask.resolve(confirmedState)
        } catch (e) {
          nextTask.reject(e)
        }
      }
    } finally {
      onSuccess(confirmedState)
      queue.activeTask = null
      queue.queuedTask = null
    }
  }

  function queueToggle(isOn: boolean): Promise<TServerState> {
    return new Promise((resolve, reject) => {
      // This is a toggle, so the next queued value can safely replace the queued one.
      if (queue.queuedTask) {
        queue.queuedTask.reject(new (AbortError as any)())
      }
      queue.queuedTask = {isOn, resolve, reject}
      processQueue()
    })
  }

  const queueToggleRef = useRef(queueToggle)
  useEffect(() => {
    queueToggleRef.current = queueToggle
  })
  const queueToggleStable = useCallback(
    (isOn: boolean): Promise<TServerState> => {
      const queueToggleLatest = queueToggleRef.current
      return queueToggleLatest(isOn)
    },
    [],
  )
  return queueToggleStable
}