authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-11 10:33:40-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-11 10:46:11-07:00
log02fca8a6248c2898025748f41c9bf5252094fd9e
tree3332417fb3f91a7c9a7a88aae18ccd9dca75fd5e
parent80c96f121cc244ce23a642320536bb7880367eed
signature Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ

fix: clear pending delay on fast mutation failures

A failure within the 200ms pending delay notified "mutating" a second time, which replaced pendingTimer without clearing the first timer. That orphaned timer later set isPending after the mutation had gone idle, leaving it stuck. Assisted-by: claude-opus-5

4 files changed, 51 insertions(+), 2 deletions(-)

jsr.json+1-1
......@@ -1,6 +1,6 @@
11{
22 "name": "@clo/react-mutation",
3 "version": "3.0.1",
3 "version": "3.0.2",
44 "exports": {
55 ".": "./src/mod.ts",
66 "./tanstack-query": "./src/tanstack-query.ts",
readme.changes.md+7
......@@ -1,5 +1,12 @@
11# notable changes in React Mutation
22
3## v3.0.2
4
5### bugfixes
6
7- resolve `useMutate` leaving `isPending` stuck `true` when a mutation fails
8 within the 200ms pending delay
9
310## v3.0.1
411
512### bugfixes
src/react.ts+1-1
......@@ -305,7 +305,7 @@ class Observer<Args extends unknown[], Result> {
305305 });
306306
307307 if (!this.state.isPending && this.state.isMutating && !debounced) {
308 this.pendingTimer = setTimeout(() => {
308 this.pendingTimer ??= setTimeout(() => {
309309 this.pendingTimer = null;
310310 this.setState({ isPending: true });
311311 }, 200);
test/useMutate.test.tsx+42
......@@ -94,6 +94,48 @@ test("useMutate - global error and success handling", async () => {
9494 ]);
9595});
9696
97test("useMutate - a failure before the pending delay never leaves isPending stuck", async () => {
98 vi.useFakeTimers({ shouldAdvanceTime: true });
99 const user = userEvent.setup({ delay: null });
100
101 const { client } = createTestMutationClient();
102 const s = new IterableStream<string>();
103
104 const mutTest = client.define({
105 id: "test-fast-failure",
106 mutate: async () => {
107 return (await s.next()).value;
108 },
109 describe: "Test the action",
110 describeResult: null,
111 optimistic: () => {},
112 });
113
114 let state = {};
115 function TestComponent() {
116 const { run, isMutating, isPending, errorMessage } = useMutate(mutTest);
117 state = { isMutating, isPending, errorMessage };
118 return (
119 <button data-testid="a" onClick={() => run()}>
120 button
121 </button>
122 );
123 }
124
125 render(<TestComponent />);
126 await act(() => user.click(screen.getByTestId("a")));
127 await act(async () => {
128 s.throw(new Error("damn!"));
129 vi.advanceTimersByTime(50);
130 });
131 await act(() => vi.advanceTimersByTime(500));
132 assertEquals(state, {
133 isMutating: false,
134 isPending: false,
135 errorMessage: "Could not test the action: damn!",
136 });
137});
138
97139test("useMutate - local error and success handling", async () => {
98140 vi.useFakeTimers({ shouldAdvanceTime: true });
99141 const user = userEvent.setup({ delay: null });