about summary refs log tree commit diff
path: root/src/lib/hooks/useToggleMutationQueue.ts
diff options
context:
space:
mode:
authordan <dan.abramov@gmail.com>2023-11-16 22:01:01 +0000
committerGitHub <noreply@github.com>2023-11-16 14:01:01 -0800
commit8475312422579c2c7d2798fcd0ff58009c366690 (patch)
treea2576ed4e7766aac25d16a74ef802866786ea12e /src/lib/hooks/useToggleMutationQueue.ts
parent54faa7e176ed2f8644ef4941c8a65522107a84c1 (diff)
downloadvoidsky-8475312422579c2c7d2798fcd0ff58009c366690.tar.zst
Add a mutation queue to fix race conditions in toggles (#1933)
* Prototype a queue

* Track both current and pending actions

* Skip unnecessary actions

* Commit last confirmed state to shadow

* Thread state through actions over time

* Fix the logic to skip redundant mutations

* Track status

* Extract an abstraction

* Fix standalone mutations

* Add types

* Move to another file

* Return stable function

* Clean up

* Use queue for muting

* Use queue for blocking

* Convert other follow buttons

* Don't export non-queue mutations

* Properly handle canceled tasks

* Fix copy paste
Diffstat (limited to 'src/lib/hooks/useToggleMutationQueue.ts')
-rw-r--r--src/lib/hooks/useToggleMutationQueue.ts98
1 files changed, 98 insertions, 0 deletions
diff --git a/src/lib/hooks/useToggleMutationQueue.ts b/src/lib/hooks/useToggleMutationQueue.ts
new file mode 100644
index 000000000..28ae86142
--- /dev/null
+++ b/src/lib/hooks/useToggleMutationQueue.ts
@@ -0,0 +1,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
+}