Thanks to visit codestin.com
Credit goes to github.com

Skip to content
/ dirge Public

Hierarchical resource management for JavaScript. Clean up subscriptions, event listeners, and disposables with nested scopes that match your component lifecycle.

Notifications You must be signed in to change notification settings

catpea/dirge

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

11 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

dirge

A lightweight, hierarchical resource management system for JavaScript. Handles cleanup of subscriptions, event listeners, timers, and any other disposable resources in a structured, scope-based manner.

Features

  • 🎯 Universal cleanup: Works with any cleanup pattern (functions, Symbol.dispose, etc.)
  • 🌲 Hierarchical scopes: Organize resources in a tree structure
  • 🔄 Correct disposal order: Children dispose first, local resources in reverse
  • 🪶 Lightweight: No dependencies, minimal API surface
  • 🎨 Flexible: Use nested scopes or path-based lookup

Installation

npm i dirge

Quick Start

import { ManagedResourceScope } from 'dirge';

class Component {
  #disposal = new ManagedResourceScope('component');

  constructor() {
    // Add any cleanup function
    this.#disposal.add(() => console.log('cleanup!'));

    // Subscriptions
    this.#disposal.add(() => signal.unsubscribe());

    // Event listeners
    const handler = () => {};
    this.#disposal.add(() => {
      button.removeEventListener('click', handler);
    });

    // AbortController
    const controller = new AbortController();
    this.#disposal.add(() => controller.abort());

    // Timer
    const timer = setInterval(() => {}, 1000);
    this.#disposal.add(() => clearInterval(timer));
  }

  destroy() {
    this.#disposal.dispose(); // Cleans everything
  }
}

Hierarchical Scopes

Organize related resources into nested scopes:

import { ManagedResourceScope } from 'dirge';

class App {
  #disposal = new ManagedResourceScope('app');

  constructor() {
    // Main app resources
    this.#disposal.add(() => console.log('app cleanup'));

    // UI scope
    const ui = this.#disposal.scope('ui');
    ui.add(() => console.log('ui cleanup'));

    // Modal within UI
    const modal = ui.scope('modal');
    modal.add(() => console.log('modal cleanup'));

    // Keep reference for later
    this.modalScope = modal;
  }

  closeModal() {
    // Dispose only the modal
    this.modalScope.dispose();
  }

  destroy() {
    // Disposes all: modal → ui → app (depth-first)
    this.#disposal.dispose();
  }
}

API

Constructor

const scope = new ManagedResourceScope(name);

Methods

add(resource)

Add a resource to be disposed. Resource must be either:

  • A function: () => cleanup()
  • An object with Symbol.dispose method
scope.add(() => subscription.unsubscribe());
scope.add(() => controller.abort());

scope(name)

Create or get a child scope. Returns a ManagedResourceScope.

const childScope = scope.scope('child');
childScope.add(() => console.log('child cleanup'));

lookup(path)

Get a nested scope by path. Less type-safe than keeping references.

const modal = scope.lookup('ui/modal');
// Equivalent to: scope.scope('ui').scope('modal')

dispose()

Dispose all resources in this scope and all children. Disposal order:

  1. All child scopes (recursively)
  2. Local resources (in reverse order of addition)
scope.dispose();

Properties

path (getter)

Get the full path of this scope from root.

const modal = root.scope('ui').scope('modal');
console.log(modal.path); // "/ui/modal"

parent

Reference to parent scope (or undefined for root).

children

Map of child scopes: Map<string, ManagedResourceScope>

Patterns

Component Lifecycle

import { ManagedResourceScope } from 'dirge';

class Component {
  #disposal = new ManagedResourceScope('component');

  mount() {
    this.#disposal.add(() => element.remove());
    this.#disposal.add(() => signal.unsubscribe());
  }

  unmount() {
    this.#disposal.dispose();
  }
}

Temporary Subscriptions

showDialog() {
  const dialogScope = this.#disposal.scope('dialog');

  dialogScope.add(() => overlay.remove());
  dialogScope.add(() => controller.abort());

  // Later...
  dialogScope.dispose(); // Just the dialog
}

Fetch with Cleanup

async fetchData() {
  const fetchScope = this.#disposal.scope('fetch');
  const controller = new AbortController();

  fetchScope.add(() => controller.abort());

  try {
    const response = await fetch(url, {
      signal: controller.signal
    });
    return await response.json();
  } finally {
    fetchScope.dispose();
  }
}

Debugging

Visualize your disposal tree:

DOM Visualization

// DOM visualization
export function visualizeDisposalTree(scope, rootName = "root") {
  const ul = document.createElement("ul");
  ul.style.fontFamily = "monospace";
  ul.style.lineHeight = "1.6";

  function buildTree(scope, parentUl) {
    const li = document.createElement("li");
    li.innerHTML = `<strong>${scope.name || rootName}</strong> ` + `<span style="color: #666;">(${scope.size} resources)</span>`;
    parentUl.appendChild(li);

    if (scope.children.size > 0) {
      const childUl = document.createElement("ul");
      li.appendChild(childUl);
      for (const child of scope.children.values()) {
        buildTree(child, childUl);
      }
    }
  }

  buildTree(scope, ul);
  return ul;
}

// Usage
document.body.appendChild(visualizeDisposalTree(myScope));

Console Visualization

export function logDisposalTree(scope, rootName = "root", indent = "") {
  const name = scope.name || rootName;
  console.log(`${indent}├─ ${name} (${scope.size} resources)`);

  const children = Array.from(scope.children.values());
  children.forEach((child, i) => {
    const isLast = i === children.length - 1;
    const newIndent = indent + (isLast ? "   " : "│  ");
    logDisposalTree(child, child.name, newIndent);
  });
}


// Usage
logDisposalTree(myScope);
// Output:
// ├─ app (2 resources)
// │  ├─ ui (1 resources)
// │     ├─ modal (3 resources)

Why Not Just Arrays?

The real problem emerges when components have different lifecycle scopes - persistent resources that live for the component's lifetime, and temporary resources that change as the user navigates through states.

The Problem: Nested Component Lifecycles

Consider a login component with multiple stages:

class AuthComponent {
  #componentScope; // Lives until user logs in
  #userFormScope;  // Recreated when switching login/forgot password

  initialize() {
    // Component-level: survives form switches
    this.#componentScope = app.disposer.scope('auth-component');

    // Subscribe to user profile signal - lives entire component lifetime
    this.#componentScope.add(() => userProfile.unsubscribe());

    // Setup form validation - component-level
    this.#componentScope.add(() => validator.destroy());
  }

  start() {
    // Show login form
    this.showLoginForm();
  }

  showLoginForm() {
    // Clear old form scope if exists
    this.#userFormScope?.dispose();

    // Create fresh scope for this form
    this.#userFormScope = this.#componentScope.scope('loginForm');

    // Form-specific subscriptions
    this.#userFormScope.add(() => {
      loginButton.removeEventListener('click', this.handleLogin);
    });
    this.#userFormScope.add(() => loginFormSignal.unsubscribe());

    // Nested scope for dynamic validation messages
    const validationScope = this.#userFormScope.scope('validation');
    validationScope.add(() => clearValidationUI());
  }

  showForgotPasswordForm() {
    // Automatically cleans loginForm + all its nested scopes
    this.#userFormScope?.dispose();

    // Fresh scope for forgot password
    this.#userFormScope = this.#componentScope.scope('forgotForm');

    this.#userFormScope.add(() => {
      resetButton.removeEventListener('click', this.handleReset);
    });
    this.#userFormScope.add(() => forgotFormSignal.unsubscribe());
  }

  terminate() {
    // Cleanup everything: component scope + any active form scope
    // All nested scopes cleaned automatically
    this.#componentScope.dispose();
  }
}

With Arrays: Chaos

// ❌ Array approach - error prone and messy
class AuthComponent {
  #componentCleanups = [];
  #formCleanups = [];

  initialize() {
    this.#componentCleanups.push(() => userProfile.unsubscribe());
    this.#componentCleanups.push(() => validator.destroy());
  }

  showLoginForm() {
    // Manual cleanup - easy to forget
    this.#formCleanups.forEach(fn => fn());
    this.#formCleanups = [];

    // Add new cleanups
    this.#formCleanups.push(() => loginButton.removeEventListener(...));
    this.#formCleanups.push(() => loginFormSignal.unsubscribe());

    // Wait, where do validation cleanups go?
    // Need another array? #validationCleanups?
    // What about nested wizard steps? #wizardStepCleanups?
    // This gets messy fast...
  }

  showForgotPasswordForm() {
    // Remember to cleanup, every time, manually
    this.#formCleanups.forEach(fn => fn());
    this.#formCleanups = [];

    this.#formCleanups.push(() => resetButton.removeEventListener(...));
    // Easy to forget cleanups or mix up arrays
  }

  terminate() {
    // Cleanup in correct order? Hope you remembered everything
    this.#formCleanups.forEach(fn => fn());
    this.#componentCleanups.forEach(fn => fn());
    // Did we cleanup validation? Nested scopes? Who knows!
  }
}

With ManagedResourceScope: Clear Structure

// ✅ ManagedResourceScope - organized and automatic
class AuthComponent {
  #componentScope;
  #userFormScope;

  initialize() {
    this.#componentScope = app.disposer.scope('auth-component');
    this.#componentScope.add(() => userProfile.unsubscribe());
    this.#componentScope.add(() => validator.destroy());
  }

  showLoginForm() {
    // One line - cleans everything nested inside
    this.#userFormScope?.dispose();

    this.#userFormScope = this.#componentScope.scope('loginForm');
    this.#userFormScope.add(() => loginButton.removeEventListener(...));

    // Nest as deep as needed - cleanup is automatic
    const validation = this.#userFormScope.scope('validation');
    validation.add(() => clearValidationUI());

    const wizard = validation.scope('wizard-step-2');
    wizard.add(() => wizardCleanup());
    // All cleaned when userFormScope.dispose() is called
  }

  terminate() {
    // One line - guarantees everything is cleaned
    // componentScope → userFormScope → validation → wizard-step-2
    // All disposed in correct order (depth-first)
    this.#componentScope.dispose();
  }
}

The Key Insight

Arrays force you to manually track and cleanup parallel hierarchies. Scopes give you a tree structure that matches your component's actual lifecycle.

When you call componentScope.dispose():

  • loginForm scope is disposed first (and all its children)
  • Then componentScope's own resources are disposed
  • You can trust every nested scope is cleaned up - no manual tracking needed

This is especially critical in:

  • Multi-step wizards - each step creates/destroys scopes
  • Tabbed interfaces - tabs have different resource lifetimes
  • Dynamic forms - fields appear/disappear based on user input
  • Modal/dialog chains - modals can spawn other modals

With arrays, you'd need separate arrays for each level and manual coordination. With scopes, the tree structure does the work for you.

License

MIT

About

Hierarchical resource management for JavaScript. Clean up subscriptions, event listeners, and disposables with nested scopes that match your component lifecycle.

Topics

Resources

Stars

Watchers

Forks

Packages

No packages published