diff --git a/CHANGELOG.md b/CHANGELOG.md
index fef797c..18673f1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,10 @@
# Changelog
+## 0.13.0
+
+### Feature
+* Adds new `onAction` method that triggers callback whenever specific action is called.
+
## 0.12.4
### Bugfixes
diff --git a/README.md b/README.md
index e80cafc..5c3cbc9 100644
--- a/README.md
+++ b/README.md
@@ -170,6 +170,45 @@ function App() {
```
[__Open in Codesandbox__](https://codesandbox.io/s/exome-counter-96qfq)
+### `onAction`
+Function that calls callback whenever specific action on Exome is called.
+
+```ts
+function onAction(store: typeof Exome): Unsubscribe
+```
+
+__Arguments__
+1. `store` _([Exome](#exome) constructor)_: Store that has desired action to listen to.
+2. `action` _(string)_: method (action) name on store instance.
+3. `callback` _(Function)_: Callback that will be triggered before or after action.
+ __Arguments__
+ - `instance` _([Exome](#exome))_: Instance where action is taking place.
+ - `action` _(String)_: Action name.
+ - `payload` _(any[])_: Array of arguments passed in action.
+4. `type` _("before" | "after")_: when to run callback - before or after action, default is `"after"`.
+
+__Returns__
+
+- _Function_: Unsubscribes this action listener
+
+__Example__
+
+```tsx
+import { onAction } from "exome"
+
+const unsubscribe = onAction(
+ Person,
+ 'rename',
+ (instance, action, payload) => {
+ console.log(`Person ${instance} was renamed to ${payload[0]}`);
+
+ // Unsubscribe is no longer needed
+ unsubscribe();
+ },
+ 'before'
+)
+```
+
### `saveState`
Function that saves snapshot of current state for any Exome and returns string.
diff --git a/package-lock.json b/package-lock.json
index 3f9d51c..7401e5e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "exome",
- "version": "0.12.4",
+ "version": "0.13.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "exome",
- "version": "0.12.4",
+ "version": "0.13.0",
"license": "MIT",
"devDependencies": {
"@types/node": "^14.14.41",
diff --git a/package.json b/package.json
index 77c132b..e6851ab 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "exome",
- "version": "0.12.4",
+ "version": "0.13.0",
"description": "Proxy based store manager for deeply nested states",
"main": "exome.js",
"module": "exome.esm.js",
diff --git a/src/index.ts b/src/index.ts
index 7b72c11..564d6a3 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -7,3 +7,4 @@ export { updateView } from './utils/update-view'
export { saveState } from './utils/save-state'
export { loadState, registerLoadable } from './utils/load-state'
export { addMiddleware, Middleware } from './middleware'
+export { onAction } from './utils/on-action'
diff --git a/src/middleware.test.ts b/src/middleware.test.ts
index dd710ab..92b8faf 100644
--- a/src/middleware.test.ts
+++ b/src/middleware.test.ts
@@ -70,4 +70,56 @@ test('runs middleware unsubscribe method', () => {
assert.equal(unsubscribe.args[0], [])
})
+test('removes middleware correctly', () => {
+ const instance = new Exome()
+ const middleware = fake()
+
+ const unsubscribe = addMiddleware(middleware)
+
+ runMiddleware(instance, 'actionName', [])
+
+ assert.equal(middleware.callCount, 1)
+ assert.equal(middleware.args[0], [instance, 'actionName', []])
+
+ runMiddleware(instance, 'actionName', [])
+
+ assert.equal(middleware.callCount, 2)
+ assert.equal(middleware.args[1], [instance, 'actionName', []])
+
+ unsubscribe()
+ runMiddleware(instance, 'actionName', [])
+
+ assert.equal(middleware.callCount, 2)
+})
+
+test('removes multiple middleware correctly', () => {
+ const instance = new Exome()
+ const middleware1 = fake()
+ const middleware2 = fake()
+
+ const unsubscribe1 = addMiddleware(middleware1)
+ const unsubscribe2 = addMiddleware(middleware2)
+
+ runMiddleware(instance, 'actionName', [])
+
+ assert.equal(middleware1.callCount, 1)
+ assert.equal(middleware1.args[0], [instance, 'actionName', []])
+ assert.equal(middleware2.callCount, 1)
+ assert.equal(middleware2.args[0], [instance, 'actionName', []])
+
+ runMiddleware(instance, 'actionName', [])
+
+ assert.equal(middleware1.callCount, 2)
+ assert.equal(middleware1.args[1], [instance, 'actionName', []])
+ assert.equal(middleware2.callCount, 2)
+ assert.equal(middleware2.args[1], [instance, 'actionName', []])
+
+ unsubscribe1()
+ unsubscribe2()
+ runMiddleware(instance, 'actionName', [])
+
+ assert.equal(middleware1.callCount, 2)
+ assert.equal(middleware2.callCount, 2)
+})
+
test.run()
diff --git a/src/middleware.ts b/src/middleware.ts
index cd36a6d..7eb550d 100644
--- a/src/middleware.ts
+++ b/src/middleware.ts
@@ -3,10 +3,15 @@ import { Exome } from './exome'
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
export type Middleware = (instance: Exome, action: string, payload: any[]) => (void | (() => void))
-const middleware: Middleware[] = []
+export const middleware: Middleware[] = []
export function addMiddleware(fn: Middleware) {
middleware.push(fn)
+
+ return () => {
+ const index = middleware.indexOf(fn)
+ middleware.splice(index, 1)
+ }
}
export function runMiddleware(...params: Parameters) {
diff --git a/src/utils/on-action.test.ts b/src/utils/on-action.test.ts
new file mode 100644
index 0000000..e0c9fd9
--- /dev/null
+++ b/src/utils/on-action.test.ts
@@ -0,0 +1,203 @@
+import { fake } from 'sinon'
+import { test } from 'uvu'
+import assert from 'uvu/assert'
+
+import { Exome } from '../exome'
+import { middleware, runMiddleware } from '../middleware'
+
+import { onAction } from './on-action'
+
+test.before.each(() => {
+ middleware.splice(0, 100)
+})
+
+test('exports `onAction`', () => {
+ assert.ok(onAction)
+})
+
+test('that `onAction` is function', () => {
+ assert.instance(onAction, Function)
+})
+
+test('adds before middleware without errors', () => {
+ class Person extends Exome {
+ constructor(
+ public name?: string
+ ) {
+ super()
+ }
+
+ public rename(name: string) {
+ this.name = name
+ }
+ }
+
+ const person = new Person('John')
+ const handler = fake()
+
+ onAction(Person, 'rename', handler, 'before')
+
+ const after = runMiddleware(person, 'rename', [1])
+
+ assert.equal(handler.callCount, 1)
+ assert.equal(handler.args[0], [person, 'rename', [1]])
+
+ after()
+
+ assert.equal(handler.callCount, 1)
+})
+
+test('adds after middleware without errors', () => {
+ class Person extends Exome {
+ constructor(
+ public name?: string
+ ) {
+ super()
+ }
+
+ public rename(name: string) {
+ this.name = name
+ }
+ }
+
+ const person = new Person('John')
+ const handler = fake()
+
+ onAction(Person, 'rename', handler, 'after')
+
+ const after = runMiddleware(person, 'rename', [1])
+
+ assert.equal(handler.callCount, 0)
+
+ after()
+
+ assert.equal(handler.callCount, 1)
+ assert.equal(handler.args[0], [person, 'rename', [1]])
+})
+
+test('calls NEW action correctly', async() => {
+ class Person extends Exome {
+ constructor(
+ public name?: string
+ ) {
+ super()
+ }
+
+ public rename(name: string) {
+ this.name = name
+ }
+ }
+
+ const handler = fake()
+ onAction(Person, 'NEW', handler)
+
+ assert.equal(handler.callCount, 0)
+
+ // eslint-disable-next-line no-new
+ new Person('John')
+
+ await new Promise((resolve) => setTimeout(resolve, 10))
+
+ assert.equal(handler.callCount, 1)
+ assert.instance(handler.args[0][0], Person)
+ assert.equal(handler.args[0][1], 'NEW')
+ assert.equal(handler.args[0][2].length, 0)
+})
+
+test('calls any action correctly', async() => {
+ class Person extends Exome {
+ constructor(
+ public name?: string
+ ) {
+ super()
+ }
+
+ public rename(name: string) {
+ this.name = name
+ }
+ }
+
+ const handler = fake()
+ onAction(Person, null, handler)
+
+ assert.equal(handler.callCount, 0)
+
+ const person = new Person('John')
+
+ await new Promise((resolve) => setTimeout(resolve, 10))
+
+ assert.equal(handler.callCount, 1)
+ assert.instance(handler.args[0][0], Person)
+ assert.equal(handler.args[0][1], 'NEW')
+ assert.equal(handler.args[0][2].length, 0)
+
+ person.rename('Jane')
+
+ assert.equal(handler.callCount, 2)
+ assert.equal(handler.args[1], [person, 'rename', ['Jane']])
+})
+
+test('calls custom action correctly', () => {
+ class Person extends Exome {
+ constructor(
+ public name?: string
+ ) {
+ super()
+ }
+
+ public rename(name: string) {
+ this.name = name
+ }
+ }
+
+ const handler = fake()
+ onAction(Person, 'rename', handler)
+
+ assert.equal(handler.callCount, 0)
+
+ const person = new Person('John')
+
+ assert.equal(handler.callCount, 0)
+
+ person.rename('Jane')
+
+ assert.equal(handler.callCount, 1)
+ assert.equal(handler.args[0], [person, 'rename', ['Jane']])
+})
+
+test('unsubscribes correctly', () => {
+ class Person extends Exome {
+ constructor(
+ public name?: string
+ ) {
+ super()
+ }
+
+ public rename(name: string) {
+ this.name = name
+ }
+ }
+
+ const handler = fake()
+ const unsubscribe = onAction(Person, 'rename', handler)
+
+ assert.equal(handler.callCount, 0)
+
+ const person = new Person('John')
+
+ assert.equal(handler.callCount, 0)
+
+ person.rename('Jane')
+
+ assert.equal(handler.callCount, 1)
+
+ person.rename('Janine')
+
+ assert.equal(handler.callCount, 2)
+
+ unsubscribe()
+
+ assert.equal(handler.callCount, 2)
+})
+
+test.run()
diff --git a/src/utils/on-action.ts b/src/utils/on-action.ts
new file mode 100644
index 0000000..55b8abe
--- /dev/null
+++ b/src/utils/on-action.ts
@@ -0,0 +1,26 @@
+import { Exome } from '../exome'
+import { addMiddleware } from '../middleware'
+
+type Unsubscribe = () => void
+
+export function onAction(
+ Parent: new (...args: any[]) => T,
+ action: null | 'NEW' | keyof T,
+ callback: (instance: T, action: 'NEW' | keyof T, payload: any[]) => void,
+ type: 'before' | 'after' = 'after'
+): Unsubscribe {
+ return addMiddleware((instance, targetAction, payload) => {
+ if (!(instance instanceof Parent && (targetAction === action || action === null))) {
+ return
+ }
+
+ if (targetAction === 'NEW' || type === 'before') {
+ callback(instance, targetAction as any, payload)
+ return
+ }
+
+ return () => {
+ callback(instance, targetAction as any, payload)
+ }
+ })
+}