From f66143adfb800a1341a3eacd691a736607561533 Mon Sep 17 00:00:00 2001 From: "xan.lol" Date: Tue, 29 Sep 2026 19:15:30 -0700 Subject: [PATCH] fix bumping reposts --- src/lib/hooks/useToggleMutationQueue.test.ts | 165 +++++++++++++++++++ src/lib/hooks/useToggleMutationQueue.ts | 28 +++- 2 files changed, 185 insertions(+), 8 deletions(-) create mode 100644 src/lib/hooks/useToggleMutationQueue.test.ts diff --git a/src/lib/hooks/useToggleMutationQueue.test.ts b/src/lib/hooks/useToggleMutationQueue.test.ts new file mode 100644 index 000000000..11992ef12 --- /dev/null +++ b/src/lib/hooks/useToggleMutationQueue.test.ts @@ -0,0 +1,165 @@ +import {act, renderHook} from '@testing-library/react-native' + +import {useToggleMutationQueue} from '#/lib/hooks/useToggleMutationQueue' + +function deferred() { + let resolve!: (value: T) => void + let reject!: (error: Error) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return {promise, resolve, reject} +} + +test('undo and bump use the confirmed repost URI before a rerender', async () => { + let nextRepost = 0 + const runMutation = jest.fn((_prev: string | undefined, isOn: boolean) => + Promise.resolve(isOn ? `repost-${++nextRepost}` : undefined), + ) + const onSuccess = jest.fn() + const {result} = renderHook(() => + useToggleMutationQueue({ + initialState: undefined, + runMutation, + onSuccess, + }), + ) + + await act(async () => { + await result.current(true) + await result.current(false) + await result.current(true) + await result.current(false) + await result.current(true) + }) + + expect(runMutation.mock.calls).toEqual([ + [undefined, true], + ['repost-1', false], + [undefined, true], + ['repost-2', false], + [undefined, true], + ]) + expect(onSuccess).toHaveBeenLastCalledWith('repost-3') +}) + +test('duplicate queued requests settle without creating another repost', async () => { + const request = deferred() + const runMutation = jest.fn(() => request.promise) + const {result} = renderHook(() => + useToggleMutationQueue({ + initialState: undefined, + runMutation, + onSuccess: jest.fn(), + }), + ) + + await act(async () => { + const first = result.current(true) + const second = result.current(true) + request.resolve('repost-1') + await expect(first).resolves.toBe('repost-1') + await expect(second).resolves.toBe('repost-1') + }) + expect(runMutation).toHaveBeenCalledTimes(1) +}) + +test('a queued retry runs after the preceding request fails', async () => { + const request = deferred() + const runMutation = jest + .fn() + .mockImplementationOnce(() => request.promise) + .mockResolvedValueOnce('repost-1') + const {result} = renderHook(() => + useToggleMutationQueue({ + initialState: undefined, + runMutation, + onSuccess: jest.fn(), + }), + ) + + await act(async () => { + const first = result.current(true).catch(error => error) + const retry = result.current(true) + const error = new Error('Network failure') + request.reject(error) + expect(await first).toBe(error) + await expect(retry).resolves.toBe('repost-1') + }) + expect(runMutation).toHaveBeenCalledTimes(2) +}) + +test('an undo queued during creation deletes the new repost', async () => { + const request = deferred() + const runMutation = jest + .fn() + .mockImplementationOnce(() => request.promise) + .mockResolvedValueOnce(undefined) + const onSuccess = jest.fn() + const {result, rerender} = renderHook( + ({initialState}: {initialState: string | undefined}) => + useToggleMutationQueue({initialState, runMutation, onSuccess}), + {initialProps: {initialState: undefined as string | undefined}}, + ) + + let create!: Promise + let undo!: Promise + act(() => { + create = result.current(true) + }) + rerender({initialState: 'pending'}) + act(() => { + undo = result.current(false) + }) + await act(async () => { + request.resolve('repost-1') + await create + await undo + }) + + expect(runMutation).toHaveBeenLastCalledWith('repost-1', false) + expect(onSuccess).toHaveBeenLastCalledWith(undefined) +}) + +test('a failed bump creation leaves the confirmed state unreposted', async () => { + const error = new Error('Network failure') + const runMutation = jest + .fn() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(error) + const onSuccess = jest.fn() + const {result} = renderHook(() => + useToggleMutationQueue({ + initialState: 'repost-1', + runMutation, + onSuccess, + }), + ) + + await act(async () => { + await result.current(false) + await expect(result.current(true)).rejects.toBe(error) + }) + expect(runMutation).toHaveBeenLastCalledWith(undefined, true) + expect(onSuccess).toHaveBeenLastCalledWith(undefined) +}) + +test('idle queues accept refreshed server state', async () => { + const runMutation = jest.fn().mockResolvedValue(undefined) + const {result, rerender} = renderHook( + ({initialState}: {initialState: string | undefined}) => + useToggleMutationQueue({ + initialState, + runMutation, + onSuccess: jest.fn(), + }), + {initialProps: {initialState: undefined as string | undefined}}, + ) + rerender({initialState: 'refreshed-repost'}) + + await act(async () => { + await result.current(false) + }) + expect(runMutation).toHaveBeenCalledWith('refreshed-repost', false) +}) diff --git a/src/lib/hooks/useToggleMutationQueue.ts b/src/lib/hooks/useToggleMutationQueue.ts index 24e327fe9..892fc261d 100644 --- a/src/lib/hooks/useToggleMutationQueue.ts +++ b/src/lib/hooks/useToggleMutationQueue.ts @@ -9,6 +9,8 @@ type Task = { type TaskQueue = { activeTask: Task | null queuedTask: Task | null + /** Keep server-confirmed state between drains, before React rerenders. */ + confirmedState: TServerState } function AbortError() { @@ -34,39 +36,49 @@ export function useToggleMutationQueue({ const [queue] = useState>({ activeTask: null, queuedTask: null, + confirmedState: initialState, }) + useEffect(() => { + if (!queue.activeTask) { + queue.confirmedState = initialState + } + }, [initialState, queue]) + 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 + let previousTaskSucceeded = false try { while (queue.queuedTask) { const prevTask = queue.activeTask const nextTask = queue.queuedTask queue.activeTask = nextTask queue.queuedTask = null - if (prevTask?.isOn === nextTask.isOn) { + if (previousTaskSucceeded && prevTask?.isOn === nextTask.isOn) { // Skip multiple requests to update to the same value in a row. - prevTask.reject(new (AbortError as any)()) + nextTask.resolve(queue.confirmedState) 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) + queue.confirmedState = await runMutation( + queue.confirmedState, + nextTask.isOn, + ) + previousTaskSucceeded = true + nextTask.resolve(queue.confirmedState) } catch (e) { + previousTaskSucceeded = false nextTask.reject(e) } } } finally { - onSuccess(confirmedState) + onSuccess(queue.confirmedState) queue.activeTask = null queue.queuedTask = null } -- knotmirror 0.0.1