Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,18 @@ name: Publish to npm
on:
release:
types: [published]
permissions:
id-token: write # Required for OIDC
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '18.x'
node-version: '24.x'
registry-url: 'https://registry.npmjs.org'
- run: npm ci
- run: npm run build-lib
- run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
8 changes: 5 additions & 3 deletions .github/workflows/run-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ jobs:
run-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '18.x'
node-version: '24.x'
- run: npm ci
- run: npm run build-lib
- run: npm run build-tests
- run: npm run test
env:
BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }}
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
node_modules
dist
*.log
*.env
*.err
Expand Down
2 changes: 1 addition & 1 deletion LICENSE.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Copyright (c) 2022-2024, Niklas Rämö
Copyright (c) 2022-2025, Niklas Rämö

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

Expand Down
68 changes: 58 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ A _predictable_ event emitter for pragmatists, written in TypeScript.

- 🔮 Predictable behaviour.
- 🎯 Hits the sweet spot between features, size and performance.
- 🎁 Small footprint (561 bytes minified and gzipped to be exact).
- 🎁 Small footprint (568 bytes minified and gzipped to be exact).
- ⚡ Highly optimized and stable performance across browsers.
- 🤖 Extensively tested.
- 🍭 No runtime dependencies, what you see is what you get.
Expand Down Expand Up @@ -70,7 +70,7 @@ However, emitting speed is on par with the fastest of emitters due to an optimiz

Where things get interesting is when we start removing listeners. While most emitters need to loop an array of listeners to find the matching listener(s), Eventti can just delete the listener from the `Map` by the listener id. This is a huge performance boost when you have a lot of listeners.

In practice, Eventti and most other emitters are so fast that you don't need to worry about performance. But if you're interested in the numbers, we have some [benchmarks](./benchmarks/), which you can run with `npm run benchmark`.
In practice, Eventti and most other emitters are so fast that you don't need to worry about performance. But if you're interested in the numbers, we have some [benchmarks](./benchmarks/), which you can run with `npm run bench`.

## Getting started

Expand All @@ -93,7 +93,7 @@ const emitter = new Emitter();
<script type="importmap">
{
"imports": {
"eventti": "https://cdn.jsdelivr.net/npm/eventti@4.0.3/dist/index.js"
"eventti": "https://cdn.jsdelivr.net/npm/eventti@4/dist/index.js"
}
}
</script>
Expand Down Expand Up @@ -250,16 +250,14 @@ const emitter = new Emitter({

const listener = () => {};

// Now the listener callback is used as
// the listener id automatically...
// Now the listener itself is used as the id automatically.
const idA = emitter.on('a', listener);
console.log(idA === listener); // -> true

// ...and you can remove the listener
// with the listener callback...
// And you can remove it without an explicit id.
emitter.off('a', listener);

// ...and duplicate listeners can't be added.
// And duplicate listeners can't be added.
emitter.on('a', listener);
emitter.on('a', listener); // throws an error
```
Expand Down Expand Up @@ -304,6 +302,55 @@ unbind(); // removes the listener

Do note that this breaks the API contract as now you can't use the return value of `.on()` and `.once()` methods anymore to remove the listeners with `.off` method. But if you're okay with that, this is a nice way to make the API more ergonomic.

### Scheduled callback with deduplication (debounce / coalesce)

When scheduling work to happen later, you can use a stable listener id with `once()` to ensure only **one** callback is registered, regardless of how many times the function is called before the flush.

The `dedupe` mode controls what happens when the same ID is used again:

- `'add'` (default) — **latest** callback wins, repositioned to end of queue.
- `'update'` — **latest** callback wins, original position preserved.
- `'ignore'` — **first** callback wins, original position preserved.

For a simple single-event debounce where listener order doesn't matter, the default `'add'` works fine.

```ts
import { Emitter } from 'eventti';

const emitter = new Emitter();
const flushId = 'search:flush';
let pending = false;

export function requestSearch(query: string, run: (query: string) => void) {
emitter.once('flush', () => run(query), flushId);

if (!pending) {
pending = true;
setTimeout(() => {
pending = false;
emitter.emit('flush');
}, 250);
}
}
```

### Hot-swap a listener without changing its position (stable slot)

When you want to update a listener's implementation while keeping it at the same position in the listener queue, use `dedupe: 'update'` with a stable id.

```ts
import { Emitter } from 'eventti';

const emitter = new Emitter({ dedupe: 'update' });

emitter.on('request', () => console.log('log'), 'hook:log');
emitter.on('request', () => console.log('auth v1'), 'hook:auth');
emitter.on('request', () => console.log('metrics'), 'hook:metrics');

// Replace auth hook, but keep it between "log" and "metrics".
emitter.on('request', () => console.log('auth v2'), 'hook:auth');
```

### Building a ticker

Eventti is a great fit for building a ticker, which is a common use case for event emitters. Here's a simple example of how you could build a `requestAnimationFrame` ticker.
Expand Down Expand Up @@ -468,7 +515,7 @@ const b = () => console.log('b');
const id1 = emitter.on('test', a);
const id2 = emitter.on('test', b);

// Here we bind a and b listeners agains to "test" event, but we provide the
// Here we bind a and b listeners again to "test" event, but we provide the
// listener id manually.
const id3 = emitter.on('test', a, 'foo');
const id4 = emitter.on('test', b, 'bar');
Expand All @@ -487,8 +534,9 @@ emitter.emit('test');
// a
// b

emitter.off('test', a);
emitter.off('test', id1);
emitter.emit('test');
// a
// b
```

Expand Down
10 changes: 10 additions & 0 deletions benchmarks/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
**/.git
**/.svn
**/.hg
**/node_modules

dist
package-lock.json
.gitignore
.prettierignore

4 changes: 4 additions & 0 deletions benchmarks/.prettierrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"printWidth": 100,
"singleQuote": true
}
43 changes: 40 additions & 3 deletions benchmarks/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions benchmarks/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,22 @@
"private": true,
"type": "module",
"scripts": {
"bump-deps": "npx npm-check-updates -u",
"format": "npm run prettier-write",
"lint": "npm run prettier-check",
"prettier-check": "prettier --check --no-error-on-unmatched-pattern \"./**/*\"",
"prettier-write": "prettier --write --no-error-on-unmatched-pattern \"./**/*\"",
"test": "node --expose-gc src/index.js"
},
"devDependencies": {
"eventemitter2": "6.4.9",
"eventemitter3": "5.0.1",
"eventti": "latest",
"eventti-local": "file:..",
"mitata": "1.0.34",
"mitt": "3.0.1",
"nanoevents": "9.1.0",
"prettier": "3.7.4",
"tseep": "1.3.1"
}
}
4 changes: 2 additions & 2 deletions benchmarks/src/utils/create-emitters.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { EventEmitter as TseepEmitter } from 'tseep';
import EventEmitter2 from 'eventemitter2';
import EventEmitter3 from 'eventemitter3';
import { Emitter as EventtiEmitterLatest } from 'eventti';
import { Emitter as EventtiEmitterLocal } from '../../../dist/index.js';
import { Emitter as EventtiEmitterLocal } from 'eventti-local';
import { EMITTER_NAMES } from './emitter-names.js';

// Map of emitter names to emitter creation functions.
Expand Down Expand Up @@ -56,7 +56,7 @@ const EMITTER_MAP = new Map([
create: () => {
const nano = createNanoEmitter();

// Add once method as it is missing in mitt. We do it with the same
// Add once method as it is missing in nano. We do it with the same
// logic as in eventti to keep the benchmarks fair.
nano.once = (event, callback) => {
let isCalled = false;
Expand Down
1 change: 0 additions & 1 deletion dist/index.cjs

This file was deleted.

32 changes: 0 additions & 32 deletions dist/index.d.cts

This file was deleted.

32 changes: 0 additions & 32 deletions dist/index.d.ts

This file was deleted.

1 change: 0 additions & 1 deletion dist/index.js

This file was deleted.

Loading