diff --git a/docs/src/extensibility/extensions/action.md b/docs/src/extensibility/extensions/action.md index ea59eda8..a4f5ba10 100644 --- a/docs/src/extensibility/extensions/action.md +++ b/docs/src/extensibility/extensions/action.md @@ -38,7 +38,7 @@ npm install @harlem/extension-action To get started simply register this extension with the store you wish to extend. -```typescript{16-26,29} +```typescript{16-27,30} import actionExtension from '@harlem/extension-action'; import { @@ -61,6 +61,7 @@ const { getActionErrors, whenActionIdle, resetActionState, + abortAction, onBeforeAction, onAfterAction, onActionSuccess, @@ -134,7 +135,12 @@ async function runAction() { ### Cancelling an action -Each time an action is called it returns an instance of a `Task` class. The `Task` class is an extension of the in-built `Promise` class that adds an `abort` method you can use to terminate the action. +There are 2 ways to cancel a running action: + +- Direct +- Indirect + +The **direct** method is simply calling the action and then calling the `abort` method. Each time an action is called it returns an instance of a `Task` class. The `Task` class is an extension of the in-built `Promise` class that adds an `abort` method you can use to terminate the action. ```typescript async function runAction() { @@ -146,7 +152,15 @@ async function runAction() { } ``` -Cancelling the task will throw an `ActionAbortError`. It is recommended to wrap actions you intend on cancelling in a `try/catch` statement to handle this. +The **indirect** method is using the helper method on the store to cancel the action by name. + +```typescript +abortAction('load-user-data'); +``` + +::: warning +Cancelling the task will throw an `ActionAbortError` where the action is executed. It is recommended to wrap actions you intend on cancelling (or that are not parallel) in a `try/catch` statement to handle this. +::: ### Handling nested actions @@ -167,7 +181,7 @@ export default action('parent-action', async (id: number, mutate, controller) => ``` ### Checking action status -This extension provides a set of helper methods for checking the status of actions. There are 2 ways to check whether an action is running: +This extension provides a set of helper methods for checking the status of actions. Similar to cancelling an action, there are 2 ways to check whether an action is running: - Direct - Indirect diff --git a/extensions/action/README.md b/extensions/action/README.md index b4120f53..ab4a0a64 100644 --- a/extensions/action/README.md +++ b/extensions/action/README.md @@ -50,6 +50,7 @@ const { getActionErrors, whenActionIdle, resetActionState, + abortAction, onBeforeAction, onAfterAction, onActionSuccess, @@ -123,7 +124,12 @@ async function runAction() { ### Cancelling an action -Each time an action is called it returns an instance of a `Task` class. The `Task` class is an extension of the in-built `Promise` class that adds an `abort` method you can use to terminate the action. +There are 2 ways to cancel a running action: + +- Direct +- Indirect + +The **direct** method is simply calling the action and then calling the `abort` method. Each time an action is called it returns an instance of a `Task` class. The `Task` class is an extension of the in-built `Promise` class that adds an `abort` method you can use to terminate the action. ```typescript async function runAction() { @@ -135,6 +141,12 @@ async function runAction() { } ``` +The **indirect** method is using the helper method on the store to cancel the action by name. + +```typescript +abortAction('load-user-data'); +``` + Cancelling the task will throw an `ActionAbortError`. It is recommended to wrap actions you intend on cancelling in a `try/catch` statement to handle this. @@ -156,7 +168,7 @@ export default action('parent-action', async (id: number, mutate, controller) => ``` ### Checking action status -This extension provides a set of helper methods for checking the status of actions. There are 2 ways to check whether an action is running: +This extension provides a set of helper methods for checking the status of actions. Similar to cancelling an action, there are 2 ways to check whether an action is running: - Direct - Indirect diff --git a/extensions/action/src/index.ts b/extensions/action/src/index.ts index 4d523ff0..dfd149cf 100644 --- a/extensions/action/src/index.ts +++ b/extensions/action/src/index.ts @@ -41,6 +41,8 @@ export default function actionsExtension() { return (store: InternalStore) => { const _store = store as unknown as InternalStore; + const actionTasks = new Map>>(); + _store.write('$action-init', SENDER, state => state[STATE_PROP] = {}, true); function setActionState(state: TState & ActionStoreState, name: string) { @@ -54,6 +56,14 @@ export default function actionsExtension() { function registerAction(name: string) { _store.register('actions', name, () => () => {}); _store.write('$action-register', SENDER, state => setActionState(state, name), true); + + const tasks = new Set>(); + + actionTasks.set(name, tasks); + + return { + tasks, + }; } function incrementRunCount(name: string) { @@ -77,9 +87,9 @@ export default function actionsExtension() { } function action(name: string, body: ActionBody, options?: Partial): Action { - registerAction(name); - - const tasks = new Set>(); + const { + tasks, + } = registerAction(name); const { parallel, @@ -93,11 +103,8 @@ export default function actionsExtension() { const mutate = (mutator: Mutator) => _store.write(name, SENDER, mutator); return ((payload: TPayload, controller?: AbortController) => { - if (!parallel && tasks.size > 0) { - tasks.forEach(task => { - task.abort(); - tasks.delete(task); - }); + if (!parallel) { + abortAction(name); } if (autoClearErrors) { @@ -224,6 +231,21 @@ export default function actionsExtension() { })); } + function abortAction(name: string | string[]) { + ([] as string[]) + .concat(name) + .forEach(name => { + const tasks = actionTasks.get(name); + + if (tasks && tasks.size > 0) { + tasks.forEach(task => { + task.abort(); + tasks.delete(task); + }); + } + }); + } + const onBeforeAction = getActionTrigger(EVENTS.action.before); const onAfterAction = getActionTrigger(EVENTS.action.after); const onActionSuccess = getActionTrigger(EVENTS.action.success); @@ -237,6 +259,7 @@ export default function actionsExtension() { hasActionFailed, getActionErrors, resetActionState, + abortAction, onBeforeAction, onAfterAction, onActionSuccess, diff --git a/extensions/action/test/actions.test.ts b/extensions/action/test/actions.test.ts index e9ae02a8..359de97e 100644 --- a/extensions/action/test/actions.test.ts +++ b/extensions/action/test/actions.test.ts @@ -99,7 +99,7 @@ describe('Actions Extension', () => { expect(hasActionRun(loadUserInfoName)).toBe(true); }); - test('Handles cancellation', async () => { + test('Handles direct cancellation', async () => { const { loadUserInfo, loadUserInfoName, @@ -126,6 +126,34 @@ describe('Actions Extension', () => { } }); + test('Handles indirect cancellation', async () => { + const { + loadUserInfo, + loadUserInfoName, + } = instance; + + const { + state, + hasActionRun, + abortAction, + } = instance.store; + + const task = loadUserInfo(); + + setTimeout(() => abortAction(loadUserInfoName), 100); + + try { + await task; + } catch (error) { + expect(error).toBeInstanceOf(ActionAbortError); + } finally { + expect(state.details.firstName).toBe(''); + expect(state.details.lastName).toBe(''); + expect(state.details.age).toBe(0); + expect(hasActionRun(loadUserInfoName)).toBe(false); + } + }); + test('Handles concurrency', async () => { const { action,