Common Pitfalls When Using Cordis's Event System: A Developer's Guide to Avoiding Bugs
Always store and invoke the dispose function returned by Context.on() to prevent listener leaks, and ensure Symbol events use the exact same reference when listening and emitting to avoid silent failures.
Cordis is a powerful plugin framework that relies heavily on its event system for inter-plugin communication and lifecycle management. When using Cordis's event system, developers often encounter common pitfalls related to listener disposal, context filtering, and event name resolution that can cause subtle production bugs. Understanding the implementation details in packages/core/src/events.ts according to the cordiverse/cordis source code is essential to avoid memory leaks, execution order issues, and silent failures.
Forgetting to Dispose Event Listeners (Memory Leaks)
The most frequent source of bugs in Cordis plugins is failing to handle the disposal of event listeners. In packages/core/src/events.ts, the on() method returns a dispose function (lines 43‑52) that removes the listener from the internal _hooks array. If you do not store and later invoke this function—especially during hot‑module reloads—listeners accumulate and duplicate callbacks fire.
Correct pattern:
// Store the dispose function
const dispose = ctx.on('custom-event', (payload) => {
console.log('received:', payload);
});
// Later, cleanup to prevent leaks
dispose();
Common mistake:
// ❌ Missing disposal causes duplicate listeners on reload
ctx.on('custom-event', (payload) => console.log(payload));
Misunderstanding Symbol vs String Event Names
Cordis accepts both strings and Symbols as event identifiers, but Symbol events bypass prototype‑property checks and require reference equality. As shown in the test suite packages/core/tests/events.spec.ts, using a Symbol literal inline instead of a shared constant creates distinct identifiers that never match, leading to silent failures where listeners never trigger.
Correct usage:
// Define once and reuse the same Symbol reference
const SYNC_EVENT = Symbol('sync');
ctx.on(SYNC_EVENT, () => console.log('sync triggered'));
ctx.emit(SYNC_EVENT); // ✅ Works
Silent failure:
ctx.on(Symbol('sync'), () => console.log('never fires'));
ctx.emit(Symbol('sync')); // ❌ Different references, listener never triggered
Incorrect thisArg Usage and Context Filtering
The event system extracts a thisArg from the first argument of dispatch methods (lines 73‑75 in packages/core/src/events.ts). When you pass a Context instance as the first argument to emit, Cordis applies filtering via Context.filter, causing only listeners registered on that context (or its descendants) to execute. Misunderstanding this positional argument leads to listeners being unexpectedly skipped.
Behavior examples:
// Register on root and specific context
ctx.root.on('filtered', (msg) => console.log('root:', msg));
ctx.on('filtered', (msg) => console.log('scoped:', msg));
ctx.emit('filtered', 'hello'); // Both fire
ctx.emit(ctx, 'filtered', 'hello'); // Only scoped listener fires (filter applied)
Execution Order and Prepend Behavior
Listeners are stored in insertion order within the _hooks array. By default, new listeners append to the end, but the { prepend: true } option (lines 35‑38) inserts them at the front. Relying on default ordering when your logic depends on execution sequence causes race conditions, particularly when chaining configuration hooks via internal/update.
Order demonstration:
ctx.on('order-event', () => console.log('first registered'));
ctx.on('order-event', () => console.log('second registered'), { prepend: true });
// Output: "second registered" → "first registered"
Global Listeners and Cross-Plugin Side Effects
By default, listeners are scoped to the current context hierarchy. Setting { global: true } (lines 35‑38) registers the listener at the root level, causing it to fire for every context. This can unintentionally create cross‑plugin side effects if the listener mutates shared state without proper guards.
Example:
// Fires for any context emitting 'global-event'
ctx.on('global-event', (data) => modifySharedState(data), { global: true });
Special Dispatch Modes and Error Handling
Cordis provides specialized dispatch methods that enforce specific contracts. Violating these contracts results in thrown errors or aborted execution chains.
Waterfall next() Contract
In waterfall dispatches, each listener receives a next function and must invoke it exactly once to continue the chain (lines 25‑27). Calling next() multiple times throws an error, while omitting the call aborts subsequent listeners silently.
ctx.waterfall('config', (config, next) => {
config.value = 42;
next(); // Required to continue the chain
});
Parallel Error Aggregation
The parallel method executes listeners concurrently and aggregates rejections into an AggregateError (lines 92‑94). Ignoring this error causes unhandled promise rejections or swallowed failures.
ctx.on('error-event', async () => { throw new Error('boom'); });
try {
await ctx.parallel('error-event');
} catch (e) {
console.error(e); // AggregateError containing all rejection reasons
}
Once Disposal Nuances
Context.once() wraps on() and automatically disposes after the first invocation (lines 69‑74). However, if you destructure or lose the returned dispose function before the event fires, you lose the ability to manually clean up the pending listener during plugin teardown.
// Automatic disposal after first emit
ctx.once('init', () => console.log('initialized'));
Interference with Internal Events
The event system emits internal/dispatch (lines 75‑77), internal/listener (lines 62‑66), and internal/update hooks during normal operation. Plugins that listen to these internal events can unintentionally interfere with the framework's event flow if they modify arguments or return values.
Warning: Only hook into internal/* events if you understand the core dispatch loop, as improper handlers can break the thisArg resolution or listener execution order.
Summary
- Always dispose: Store and call the function returned by
Context.on()to prevent memory leaks during reloads. - Symbol equality: Use consistent Symbol references for event names; inline Symbols create distinct keys.
- Check thisArg: Passing a Context as the first argument to
emitfilters listeners by scope. - Mind the order: Use
{ prepend: true }to execute listeners first, but understand the default append behavior. - Global caution:
{ global: true }affects all contexts; use only when cross‑plugin communication is intentional. - Respect contracts: Call
next()exactly once in waterfalls, and handleAggregateErrorin parallel dispatches. - Avoid internal interference: Hooking into
internal/dispatchorinternal/listenercan destabilize the event flow.
Frequently Asked Questions
Why do I see duplicate console logs after hot‑module reloading my plugin?
You are likely registering event listeners without storing and calling the dispose function returned by ctx.on(). During a hot reload, the old plugin instance's listeners remain active in the _hooks array while the new instance registers additional listeners, causing both to fire. Always store the dispose function and call it in your plugin's dispose lifecycle hook.
What is the difference between ctx.emit('event') and ctx.emit(ctx, 'event')?
The first argument to emit is interpreted as a thisArg (lines 73‑75 in packages/core/src/events.ts). When you pass a Context instance, Cordis applies Context.filter to determine which listeners should receive the event. Passing ctx limits the dispatch to listeners registered on that specific context or its children, while omitting it broadcasts to all applicable listeners.
How do I ensure my event listener executes before others registered on the same context?
Pass { prepend: true } as the third argument to Context.on(). This inserts the listener at the beginning of the internal _hooks array (lines 35‑38), ensuring it runs before listeners added without this option. This is critical when chaining configuration updates via internal/update hooks.
Why does my waterfall event stop executing after the first listener?
Each listener in a ctx.waterfall() dispatch must call the next() callback exactly once (lines 25‑27). If your listener logic completes without invoking next(), the chain aborts silently. If you call next() multiple times, the system throws an error. Ensure your waterfall handlers explicitly invoke next() to pass control to subsequent listeners.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →