Repository: igorkamyshev/farfetched
Pull request #416 (2023-12-07 05:25:25)

Add `onAbort` to save abort handlers in `createQuery`/`createMutation`

cloudflare-pages[bot]: ## Deploying with &nbsp;<a href="https://pages.dev"><img alt="Cloudflare Pages" src="https://user-images.githubusercontent.com/23264/106598434-9e719e00-654f-11eb-9e59-6167043cfa01.png" width="16"></a> &nbsp;Cloudflare Pages

<table><tr><td><strong>Latest commit:</strong> </td><td>
<code>3a54c74</code>
</td></tr>
<tr><td><strong>Status:</strong></td><td>&nbsp;✅&nbsp; Deploy successful!</td></tr>
<tr><td><strong>Preview URL:</strong></td><td>
<a href='https://1dfd85e7.farfetched.pages.dev'>https://1dfd85e7.farfetched.pages.dev</a>
</td></tr>
<tr><td><strong>Branch Preview URL:</strong></td><td>
<a href='https://on-cancel.farfetched.pages.dev'>https://on-cancel.farfetched.pages.dev</a>
</td></tr>
</table>

[View logs](https://dash.cloudflare.com/?to=/:account/pages/view/farfetched/1dfd85e7-56c4-4a00-8668-ee44dbc6a660)


Patch:
diff --git a/.changeset/neat-ants-kiss.md b/.changeset/neat-ants-kiss.md
new file mode 100644
index 0000000..d9a2c13
--- /dev/null
+++ b/.changeset/neat-ants-kiss.md
@@ -0,0 +1,5 @@
+---
+'@farfetched/core': minor
+---
+
+Add `onAbort` to save abort handlers in `createQuery`/`createMutation`
diff --git a/apps/website/docs/.vitepress/config.js b/apps/website/docs/.vitepress/config.js
index a7d9247..8161a62 100644
--- a/apps/website/docs/.vitepress/config.js
+++ b/apps/website/docs/.vitepress/config.js
@@ -228,6 +228,7 @@ export default withMermaid(
             items: [
               { text: 'Error creators', link: '/api/utils/error_creators' },
               { text: 'Error guards', link: '/api/utils/error_guards' },
+              { text: 'onAbort', link: '/api/utils/on_abort' },
             ],
           },
           {
diff --git a/apps/website/docs/api/utils/on_abort.md b/apps/website/docs/api/utils/on_abort.md
new file mode 100644
index 0000000..bf3517a
--- /dev/null
+++ b/apps/website/docs/api/utils/on_abort.md
@@ -0,0 +1,29 @@
+# `onAbort` <Badge type="tip" text="since v0.12" />
+
+Binds a passed function to the aborting of the [_Query_](/api/primitives/query) or [_Mutation_](/api/primitives/mutation).
+
+```ts
+import { onAbort, createQuery } from '@farfetched/core';
+
+const myQuery = createQuery({
+  async handler() {
+    const abortController = new AbortController();
+
+    onAbort(() => {
+      abortController.abort();
+    });
+
+    const response = await fetch('https://example.com', {
+      signal: abortController.signal,
+    });
+
+    return response.text();
+  },
+});
+```
+
+## Limitations
+
+1. `onAbort` can only be called inside the handler of a [_Query_](/api/primitives/query) or [_Mutation_](/api/primitives/mutation). Calling it outside a handler will throw an error.
+2. `onAbort` can only be called once per handler. Calling it multiple times will throw an error.
+3. `onAbort` can only be called before asynchronous operations are started. Calling it after asynchronous operations have started will throw an error.
diff --git a/apps/website/docs/recipes/no_fetch.md b/apps/website/docs/recipes/no_fetch.md
index cc8f2a3..062f35a 100644
--- a/apps/website/docs/recipes/no_fetch.md
+++ b/apps/website/docs/recipes/no_fetch.md
@@ -11,7 +11,11 @@ import { createQuery } from '@farfetched/core';
 import axios from 'axios';
 
 const usersQuery = createQuery({
-  handler: () => axios.get('/users').then((res) => res.data),
+  async handler() {
+    const response = await axios.get('/users');
+
+    return response.data;
+  },
 });
 ```
 
@@ -32,11 +36,42 @@ import { createMutation } from '@farfetched/core';
 import axios from 'axios';
 
 const loginMutation = createMutation({
-  handler: ({ login, password }) =>
-    axios.post('/login', { login, password }).then((res) => res.data),
+  async handler({ login, password } {
+    const response = await axios.post('/login', { login, password })
+
+    return response.data;
+  },
 });
 ```
 
 That is it, `loginMutation` is a regular [_Mutation_](/api/primitives/mutation.md) that can be used in any function from Farfetched. Of course, you can use any other library to make HTTP calls the same way.
 
 Furthermore, you can consider [creating a custom _Mutation_ factory](/recipes/custom_mutation) to simplify [_Mutation_](/api/primitives/mutation.md) creation across the application.
+
+## Cancellation support
+
+Since we took control over HTTP calls from Farfetched, we need to take care about cancellation by ourselves. Fortunately, it is not hard to do. Let us see how it can be done.
+
+Farfetched provides [`onAbort`-function](/api/utils/on_abort.md) that allows to bind a passed function to the aborting of the [_Query_](/api/primitives/query.md) or [_Mutation_](/api/primitives/mutation.md). Let us use it to abort `axios`-based [_Query_](/api/primitives/query.md) 👇
+
+```ts
+import { createQuery, onAbort } from '@farfetched/core';
+import axios from 'axios';
+
+const usersQuery = createQuery({
+  async handler() {
+    const controller = new AbortController(); // [!code ++]
+    onAbort(() => controller.abort()); // [!code ++]
+
+    const response = await axios.get('/users', {
+      signal: controller.signal, // [!code ++]
+    });
+
+    return response.data;
+  },
+});
+```
+
+That is it, `usersQuery` supports cancellation now and operators like [`timeout`](/api/operators/timeout.md) will perform cancellation correctly.
+
+You can use the same approach to add cancellation support to [_Mutation_](/api/primitives/mutation.md).
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index f3faa13..056aa8b 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -59,6 +59,7 @@ export {
   type RemoteOperationError,
   type RemoteOperationParams,
 } from './remote_operation/type';
+export { onAbort } from './remote_operation/on_abort';
 
 // Validation public API
 export { type ValidationResult, type Validator } from './validation/type';
diff --git a/packages/core/src/remote_operation/create_remote_operation.ts b/packages/core/src/remote_operation/create_remote_operation.ts
index 37abb6e..a9208e8 100644
--- a/packages/core/src/remote_operation/create_remote_operation.ts
+++ b/packages/core/src/remote_operation/create_remote_operation.ts
@@ -29,6 +29,7 @@ import { type RemoteOperation } from './type';
 import { get } from '../libs/lohyphen';
 import { isAbortError } from '../errors/guards';
 import { getCallObjectEvent } from './with_call_object';
+import { allowCancelSetting, disallowCancelSetting } from './on_abort';
 
 export function createRemoteOperation<
   Params,
@@ -95,7 +96,11 @@ export function createRemoteOperation<
       { result: unknown; stale: boolean } | null,
       unknown
     >(async ({ params }) => {
-      const result = await executeFx(params);
+      allowCancelSetting();
+      const promise = executeFx(params);
+      disallowCancelSetting();
+
+      const result = await promise;
       return { result, stale: false };
     }),
   };
diff --git a/packages/core/src/remote_operation/on_abort.ts b/packages/core/src/remote_operation/on_abort.ts
new file mode 100644
index 0000000..70da062
--- /dev/null
+++ b/packages/core/src/remote_operation/on_abort.ts
@@ -0,0 +1,45 @@
+import { configurationError } from '../errors/create_error';
+
+let cancelCallback: (() => void) | null = null;
+
+let cancelIsAllowed = false;
+
+export function allowCancelSetting() {
+  cancelIsAllowed = true;
+}
+
+export function disallowCancelSetting() {
+  cancelIsAllowed = false;
+}
+
+/**
+ * Binds a callback to be called when the Operation is aborted
+ *
+ * @param callback Callback to be called when the Operation is aborted
+ */
+export function onAbort(callback: () => void) {
+  if (!cancelIsAllowed) {
+    throw configurationError({
+      reason: 'onAbort call is not allowed',
+      validationErrors: [
+        'onAbort can be called only in the context of a handler before any async operation is performed',
+      ],
+    });
+  }
+
+  if (cancelCallback) {
+    throw configurationError({
+      reason: 'onAbort call is not allowed',
+      validationErrors: ['onAbort can be called only once per operation'],
+    });
+  }
+  cancelCallback = callback;
+}
+
+export function occupyCurrentCancelCallback() {
+  const toReturn = cancelCallback;
+
+  cancelCallback = null;
+
+  return toReturn;
+}
diff --git a/packages/core/src/remote_operation/with_call_object.ts b/packages/core/src/remote_operation/with_call_object.ts
index 711c4af..9549329 100644
--- a/packages/core/src/remote_operation/with_call_object.ts
+++ b/packages/core/src/remote_operation/with_call_object.ts
@@ -9,6 +9,7 @@ import {
 
 import { Defer, createDefer } from '../libs/lohyphen';
 import { abortError } from '../errors/create_error';
+import { occupyCurrentCancelCallback } from './on_abort';
 
 export type CallObject = {
   id: string;
@@ -123,10 +124,13 @@ function createCallObject(def?: Defer<unknown, unknown>) {
     def.promise.then(finish, finish);
   }
 
+  const cancelCallback = occupyCurrentCancelCallback();
+
   const callObj: CallObject = {
     id: getCallId(),
     status: callStatus,
     abort: (error: unknown = abortError()) => {
+      cancelCallback?.();
       if (callStatus === 'finished') {
         /**
          * It is not possible to abort already finished call,


Test patch:
diff --git a/packages/core/src/query/__tests__/create_query.test.ts b/packages/core/src/query/__tests__/create_query.test.ts
index cbce714..a265d3b 100644
--- a/packages/core/src/query/__tests__/create_query.test.ts
+++ b/packages/core/src/query/__tests__/create_query.test.ts
@@ -1,7 +1,10 @@
-import { allSettled, createEffect, fork } from 'effector';
+import { allSettled, createEffect, createWatch, fork } from 'effector';
 import { describe, test, expect, vi } from 'vitest';
 
 import { createQuery } from '../create_query';
+import { onAbort } from '../../remote_operation/on_abort';
+import { createDefer } from '../../libs/lohyphen';
+import { setTimeout } from 'timers/promises';
 
 describe('core/createQuery/handler', () => {
   test('uses resolved Promise as data source', async () => {
@@ -124,3 +127,41 @@ describe('core/createQuery/effect', () => {
     expect(scope.getState(query.$data)).toBe(17);
   });
 });
+
+describe('createQuery/onAbort', () => {
+  test('no waiting and plain function', async () => {
+    const q = createQuery({
+      handler: async (_: void) => {
+        const defer = createDefer();
+
+        onAbort(() => defer.reject());
+
+        await setTimeout(10);
+        defer.resolve(null);
+
+        return defer.promise;
+      },
+    });
+
+    const scope = fork();
+
+    createWatch({
+      unit: q.__.lowLevelAPI.callObjectCreated,
+      scope,
+      fn: ({ abort }) => abort(),
+    });
+
+    const abortListener = vi.fn();
+
+    createWatch({
+      unit: q.aborted,
+      scope,
+      fn: abortListener,
+    });
+
+    await allSettled(q.start, { scope });
+
+    expect(scope.getState(q.$status)).toBe('initial');
+    expect(abortListener).toHaveBeenCalledTimes(1);
+  });
+});
diff --git a/packages/core/src/remote_operation/__test__/create_remote_operation.test.ts b/packages/core/src/remote_operation/__test__/create_remote_operation.test.ts
index dcf3140..fda01da 100644
--- a/packages/core/src/remote_operation/__test__/create_remote_operation.test.ts
+++ b/packages/core/src/remote_operation/__test__/create_remote_operation.test.ts
@@ -5,8 +5,10 @@ import { watchRemoteOperation } from '../../test_utils/watch_query';
 import { unknownContract } from '../../contract/unknown_contract';
 import { createDefer } from '../../libs/lohyphen';
 import { createRemoteOperation } from '../create_remote_operation';
-import { isAbortError, isTimeoutError } from '../../errors/guards';
+import { isTimeoutError } from '../../errors/guards';
 import { timeoutError } from '../../errors/create_error';
+import { onAbort } from '../on_abort';
+import { setTimeout as wait } from 'timers/promises';
 
 const defaultConfig = {
   name: 'test',
@@ -144,7 +146,7 @@ describe('createRemoteOperation, disable in-flight', () => {
   });
 });
 
-describe('RemoteOperation.__.lowLevelAPI.callObjectCreated', async () => {
+describe('RemoteOperation.__.lowLevelAPI.callObjectCreated', () => {
   test('Call object is emitted', async () => {
     const callObjectEmitted = vi.fn();
     const operation = createRemoteOperation({
@@ -404,3 +406,137 @@ describe('RemoteOperation.__.lowLevelAPI.callObjectCreated', async () => {
     );
   });
 });
+
+describe('RemoteOperation and onAbort callback', () => {
+  test('callObject.abort triggers onAbort callback', async () => {
+    const operation = createRemoteOperation({
+      ...defaultConfig,
+    });
+
+    const handleCancel = vi.fn();
+
+    operation.__.executeFx.use(() => {
+      onAbort(handleCancel);
+
+      return null;
+    });
+
+    const scope = fork();
+
+    createWatch({
+      unit: operation.__.lowLevelAPI.callObjectCreated,
+      scope,
+      fn: ({ abort }) => {
+        abort();
+      },
+    });
+
+    await allSettled(operation.start, { scope, params: 42 });
+
+    expect(handleCancel).toBeCalledTimes(1);
+  });
+
+  test('throw error while trying to call onAbort after async operation', async () => {
+    const operation = createRemoteOperation({
+      ...defaultConfig,
+    });
+
+    const handleCancel = vi.fn();
+
+    operation.__.executeFx.use(async () => {
+      await wait(0);
+
+      onAbort(handleCancel);
+
+      return null;
+    });
+
+    const scope = fork();
+
+    const operationFailed = vi.fn();
+    createWatch({
+      unit: operation.finished.failure,
+      scope,
+      fn: operationFailed,
+    });
+
+    await allSettled(operation.start, { scope, params: 42 });
+
+    expect(operationFailed).toBeCalledTimes(1);
+    expect(operationFailed.mock.calls).toMatchInlineSnapshot(`
+      [
+        [
+          {
+            "error": {
+              "errorType": "CONFIGURATION",
+              "explanation": "Operation is misconfigured",
+              "reason": "onAbort call is not allowed",
+              "validationErrors": [
+                "onAbort can be called only in the context of a handler before any async operation is performed",
+              ],
+            },
+            "meta": {
+              "stale": false,
+              "stopErrorPropagation": false,
+            },
+            "params": 42,
+          },
+        ],
+      ]
+    `);
+    expect(handleCancel).toBeCalledTimes(0);
+  });
+
+  test('throw error call onAbort twice', async () => {
+    const operation = createRemoteOperation({
+      ...defaultConfig,
+    });
+
+    const handleCancel = vi.fn();
+
+    operation.__.executeFx.use(async () => {
+      onAbort(handleCancel);
+
+      onAbort(() => {
+        // second call
+      });
+
+      return null;
+    });
+
+    const scope = fork();
+
+    const operationFailed = vi.fn();
+    createWatch({
+      unit: operation.finished.failure,
+      scope,
+      fn: operationFailed,
+    });
+
+    await allSettled(operation.start, { scope, params: 42 });
+
+    expect(operationFailed).toBeCalledTimes(1);
+    expect(operationFailed.mock.calls).toMatchInlineSnapshot(`
+      [
+        [
+          {
+            "error": {
+              "errorType": "CONFIGURATION",
+              "explanation": "Operation is misconfigured",
+              "reason": "onAbort call is not allowed",
+              "validationErrors": [
+                "onAbort can be called only once per operation",
+              ],
+            },
+            "meta": {
+              "stale": false,
+              "stopErrorPropagation": false,
+            },
+            "params": 42,
+          },
+        ],
+      ]
+    `);
+    expect(handleCancel).toBeCalledTimes(0);
+  });
+});