# How Cordis Handles Nested Effects for Resource Cleanup

> Learn how Cordis automatically cleans up nested resources. Its hierarchical effect system ensures proper disposal in reverse registration order when parent effects are removed.

- Repository: [Cordiverse/cordis](https://github.com/cordiverse/cordis)
- Tags: internals
- Published: 2026-08-24

---

**Cordis implements a hierarchical effect system that automatically tracks child resources and disposes them in reverse registration order when a parent effect is cleaned up.**

The `cordiverse/cordis` repository provides a robust plugin architecture where **nested effects for resource cleanup** are managed through a parent-child relationship tracked in the execution context. When you register resources using `ctx.effect()`, the framework builds a disposal tree that ensures child effects are always cleaned up before their parents, preventing memory leaks and dangling event listeners during plugin reloads or application shutdown.

## The Effect System Architecture

At the core of this mechanism is the `Fiber` class, which maintains the execution context for every plugin operation. When code invokes `ctx.effect()`, the system creates an `EffectMeta` object that serves as the backbone for tracking nested resources.

### Effect Registration and Fiber Context

The `Fiber.effect` method (located in [`packages/core/src/fiber.ts`](https://github.com/cordiverse/cordis/blob/main/packages/core/src/fiber.ts)) initializes each effect with metadata to store child references:

```typescript
// inside Fiber.effect()
const meta: EffectMeta = { label, children: [] }

```

This metadata container collects all disposables returned by the effect function. The `collect` callback within the effect wrapper inspects each disposable for the special `symbols.effect` metadata, which indicates whether the disposable represents another nested effect:

```typescript
collect: (dispose) => {
  disposables.push(dispose)               // store the disposable
  this._disposables.delete(dispose)      // remove from global list
  if (dispose[symbols.effect]) {
    meta.children.push(dispose[symbols.effect]) // ← record nested effect
  }
},

```

When an inner effect returns a disposable carrying `symbols.effect`, the outer effect's `EffectMeta` records it in the `meta.children` array, establishing the hierarchical link required for coordinated cleanup.

## How Nested Effects Are Tracked

Cordis detects **nested effects** automatically without requiring manual parent references. When an effect function calls `ctx.effect()` again to register additional resources, the new effect wrapper inherits the current fiber context and registers itself as a child of the executing parent effect.

This tracking enables the framework to distinguish between standalone disposables (like a simple function that clears a timer) and complex nested effect chains (like a plugin that registers sub-modules, each with their own event listeners).

### Child Effect Metadata

The `symbols.effect` property acts as a brand marker on disposable functions. Only effect wrappers created by `Fiber.effect` carry this symbol, allowing the collection logic to differentiate between raw cleanup functions and nested effect containers that may themselves have children.

## The Disposal Process

When an effect wrapper is invoked (either explicitly or implicitly during plugin teardown), it triggers a recursive cleanup sequence. The disposal process handles both synchronous and asynchronous resources while respecting the parent-child hierarchy.

### Reverse Order Execution

The wrapper disposal logic (implemented in the effect runtime) executes disposables in reverse registration order to ensure children are cleaned up before parents:

```typescript
// wrapper disposal logic
const dispose = () => {
  let task!: void | Promise<void>
  for (const dispose of disposables.splice(0).reverse()) {
    if (task) task = task.then(dispose)
    else {
      const result = dispose()
      if (isObject(result) && 'then' in result) task = result as any
    }
  }
  return task
}

```

This reverse iteration guarantees that if an outer effect sets up a database connection and an inner effect starts a transaction on that connection, the transaction closes before the connection is terminated.

## Implementing Nested Effects in Practice

The following example demonstrates how a plugin establishes nested effects for a timer and an event listener, ensuring both are cleaned up when the plugin unloads:

```typescript
export default class MyPlugin {
  constructor(private readonly ctx: Context) {}

  apply() {
    // Outer effect – will clean up everything inside it
    const outer = this.ctx.effect(() => {
      // Set up a timer
      const timer = setInterval(() => console.log('tick'), 1000)

      // Register a nested effect for a custom event listener
      const inner = this.ctx.effect(() => {
        const off = this.ctx.on('custom-event', () => console.log('event'))
        // Return a disposer for the listener
        return () => off()
      }, 'event listener')

      // Return a disposer that clears the timer
      return () => clearInterval(timer)
    }, 'plugin timer')
  }
}

```

When the plugin reloads or the application shuts down, calling the outer effect wrapper triggers the following sequence:
1. The `inner` effect disposes first (removing the event listener)
2. The outer timer disposes second (clearing the interval)
3. Control returns to the caller once all async operations complete

```typescript
// When the plugin is reloaded or the application shuts down:
// 1. The outer effect's wrapper is called.
// 2. Its disposer runs, which first disposes `inner` (the child effect)
//    because it was registered later.
// 3. After all children are disposed, the outer timer is cleared.
await outer()

```

## Key Files and Implementation Details

Understanding the **nested effects for resource cleanup** implementation requires familiarity with these source files:

- **[`packages/core/src/fiber.ts`](https://github.com/cordiverse/cordis/blob/main/packages/core/src/fiber.ts)** – Contains the `Fiber.effect` method and the `EffectMeta` interface that manages the `children` array for tracking nested relationships.
- **[`packages/core/src/context.ts`](https://github.com/cordiverse/cordis/blob/main/packages/core/src/context.ts)** – Extends the `Context` class with the public `effect` API that plugins use to register resources.
- **[`packages/core/tests/dispose.spec.ts`](https://github.com/cordiverse/cordis/blob/main/packages/core/tests/dispose.spec.ts)** – Provides test coverage demonstrating that child effects dispose before parent effects during cleanup cycles.
- **[`packages/hmr/tests/plugin-a.ts`](https://github.com/cordiverse/cordis/blob/main/packages/hmr/tests/plugin-a.ts)** – Shows practical usage of nested effects in hot-module replacement scenarios.

The **async support** in the disposal logic handles promises, async iterables, and generators by chaining them sequentially using `.then()`, ensuring that asynchronous cleanup operations complete before the effect is considered fully disposed.

## Summary

- **Automatic tracking**: Cordis registers every effect with the current `Fiber` and inspects disposables for `symbols.effect` to detect nested relationships.
- **Hierarchical metadata**: The `EffectMeta` interface stores child effects in the `children` array, creating a tree structure for resource ownership.
- **Deterministic cleanup**: Disposables execute in reverse registration order (LIFO), ensuring child resources release before parent resources.
- **Async normalization**: The system handles synchronous functions, promises, and async iterables uniformly through sequential chaining.
- **Introspection support**: `Fiber.getEffects()` returns the effect tree (`EffectMeta[]`) for debugging complex plugin hierarchies.

## Frequently Asked Questions

### How does Cordis prevent memory leaks when plugins reload?

Cordis prevents memory leaks by automatically disposing all effects registered within a plugin context when that plugin is unloaded. The hierarchical tracking ensures that even deeply nested resources—such as event listeners created inside timer callbacks—are properly cleaned up through the parent-child disposal chain implemented in `Fiber.effect`.

### Can effects return asynchronous cleanup functions?

Yes, effects can return promises, async iterables, or generators. The disposal logic in [`packages/core/src/fiber.ts`](https://github.com/cordiverse/cordis/blob/main/packages/core/src/fiber.ts) checks if the return value is a thenable and chains these promises using `.then()`, ensuring asynchronous cleanup operations complete before the effect wrapper resolves.

### What happens if a child effect throws during disposal?

If a child effect throws an error during disposal, the error propagates up through the promise chain. Because the system executes disposables sequentially in reverse order, a failure in one child does not prevent sibling effects from disposing, though the error will eventually reject the outer disposal promise for proper error handling by the caller.

### How can I debug the effect hierarchy in a running application?

You can inspect the current effect tree by calling `Fiber.getEffects()`, which returns an array of `EffectMeta` objects representing the active effect hierarchy. Each metadata object includes the effect label (if provided) and the `children` array, allowing you to visualize which resources are nested under specific plugins or contexts.