OnlyWith.ai by Actyra

Eli Vance Lab

Learning in public, one mistake at a time

← Back to all posts

The Disposable Pattern: Zero Memory Leaks by Design

NotepadXL Architecture Series

  1. Part 1: Why Every App Should Be a Plugin System
  2. Part 2: The Disposable Pattern — Zero Memory Leaks by Design (this post)
  3. Part 3: Write Once, Deploy Everywhere — One Codebase, Four Platforms

Memory leaks in plugin systems are the norm, not the exception. Browser extensions leak. VS Code extensions leak. WordPress plugins leak. NotepadXL's Disposable pattern and context.subscriptions make leaks structurally impossible — not by asking developers to be careful, but by making the safe path the easiest path.

The Problem: Death by a Thousand Event Listeners

Plugin systems have a fundamental tension. A plugin needs to register things — event listeners, commands, UI elements, timers. The host system needs to clean them up when the plugin is deactivated. If the host misses even one, you have a memory leak. If the plugin forgets to track even one subscription, the host cannot help.

Here is what a leaking plugin looks like in most systems:

// A plugin that leaks memory
function activate(api) {
  // Register a command - who cleans this up?
  api.commands.register('myCommand', handler);

  // Add a menu item - who removes it?
  api.menus.addItem('File', { label: 'My Feature', handler });

  // Subscribe to events - who unsubscribes?
  api.events.on('documentChanged', onDocChange);

  // Start a timer - who clears it?
  setInterval(checkForUpdates, 5000);
}

function deactivate() {
  // The developer forgot to clean up.
  // Or cleaned up some things but not others.
  // Or cleaned up things that no longer exist.
}

The developer has to remember every registration, store a reference to it, and reverse it on deactivation. Miss one, and you leak. This is a solved problem in theory — just keep a list and clean it up. But in practice, it fails constantly because it relies on developer discipline across every plugin, across every update, forever.

The Solution: The Disposable Interface

NotepadXL's answer is a single interface:

interface Disposable {
  dispose(): void;
}

That is it. One method, no arguments, no return value. Every registration in the entire plugin SDK returns a Disposable. Registering a command? Returns a Disposable. Creating a status bar item? Returns a Disposable. Subscribing to an event? Returns a Disposable.

// Every API method returns a Disposable
interface CommandAPI {
  registerCommand(id: string, handler: (...args: unknown[]) => unknown): Disposable;
  executeCommand<T>(id: string, ...args: unknown[]): Promise<T>;
  getCommands(): string[];
}

interface StatusBarAPI {
  createStatusBarItem(options: StatusBarItemOptions): StatusBarItem;
  // StatusBarItem extends Disposable
}

interface EditorAPI {
  onDidChangeDocument: Event<DocumentChangeEvent>;
  // Event returns Disposable when subscribed
}

// Even the Event type itself is Disposable-based:
interface Event<T> {
  (listener: (e: T) => void): Disposable;
}

The pattern is consistent across the entire API surface. No exceptions. No special cases. Every registration returns a Disposable, and calling dispose() on it reverses that registration completely.

The Genius: context.subscriptions

The Disposable interface alone does not prevent leaks — it just makes cleanup possible. The mechanism that makes leaks structurally impossible is the context.subscriptions array. Every plugin receives a PluginContext:

interface PluginContext {
  readonly manifest: PluginManifest;
  readonly pluginPath: string;
  readonly storagePath: string;
  readonly globalStoragePath: string;
  subscriptions: Disposable[];   // <-- This is the key
  log: PluginLogger;
}

The subscriptions array is the collection point for every resource a plugin creates. The contract is simple: push your Disposable objects into context.subscriptions, and the host system will dispose them all when the plugin is deactivated. You do not need to track them yourself. You do not need to write cleanup code. The host handles it.

Here is what a properly written plugin looks like:

export function activate(api: NotepadXLAPI, context: PluginContext): void {
  // Register a command - push the disposable
  context.subscriptions.push(
    api.commands.registerCommand('myCommand', handler)
  );

  // Create a status bar item - push it (StatusBarItem extends Disposable)
  const statusItem = api.ui.statusBar.createStatusBarItem({
    id: 'myStatus',
    alignment: 'left',
  });
  context.subscriptions.push(statusItem);

  // Subscribe to events - push the disposable
  context.subscriptions.push(
    api.editor.onDidChangeDocument(() => updateSomething())
  );
}

Insight

The subtle genius is that context.subscriptions.push() is the most natural, easiest way to use the API. You do not have to go out of your way to do the safe thing. The safe path and the easy path are the same path. Developers do the right thing not because they understand the implications, but because the alternative — storing disposables in separate variables and tracking them manually — is more work.

How the Host Cleans Up

When a plugin is deactivated — whether the user disabled it, the application is shutting down, or the plugin threw an error — the host system runs this sequence in the plugin loader:

async deactivatePlugin(pluginId: string): Promise<boolean> {
  const loaded = this.loadedPlugins.get(pluginId);
  if (!loaded) return false;
  if (loaded.info.state !== 'active') return true;

  loaded.info.state = 'deactivating';

  try {
    // Step 1: Call the plugin's own deactivate() if it exists
    if (loaded.module?.deactivate) {
      await loaded.module.deactivate();
    }

    // Step 2: Dispose ALL subscriptions
    loaded.context.subscriptions.forEach((sub) => {
      try {
        sub.dispose();
      } catch (e) {
        console.error('Error disposing subscription:', e);
      }
    });
    loaded.context.subscriptions = [];

    loaded.info.state = 'enabled';
    loaded.info.activatedAt = undefined;
    return true;
  } catch (error) {
    loaded.info.state = 'error';
    return false;
  }
}

Two things are notable here:

  1. Each disposal is wrapped in a try-catch. A failing disposal does not prevent other disposals from running. If one subscription throws, the rest still get cleaned up. This is defense in depth — a single buggy disposable cannot cause cascading failures.
  2. The subscriptions array is reset to empty after disposal. Even if the plugin is re-activated later, it starts with a clean slate. No stale references survive across activation cycles.

The Full Lifecycle in Action

Let me trace the complete lifecycle of a plugin from installation to shutdown. Every plugin exists in one of six states:

installed enabled activating active deactivating enabled
Any state error

Phase 1: Registration

When the application starts, core plugins are registered as manifest + factory pairs:

export function registerAllCorePlugins(): void {
  registerCorePlugin(
    findReplaceManifest as PluginManifest,
    () => findReplacePlugin as PluginModule
  );

  registerCorePlugin(
    wordCountManifest as PluginManifest,
    () => wordCountPlugin as PluginModule
  );
}

At this point, nothing has been instantiated. No activate() functions have been called. The system has only recorded "these plugins exist and here is how to load them." The factory pattern is critical — it defers instantiation until the plugin is actually needed.

Phase 2: Initialization

The lifecycle manager's initialize() method walks through registered plugins:

async initialize(): Promise<void> {
  if (this.initialized) return;

  const loader = getPluginLoader();

  // Register core plugin factories
  for (const { manifest, factory } of corePluginRegistrations) {
    loader.registerCorePlugin(manifest.id, factory);
  }

  // Load and install core plugins
  for (const { manifest } of corePluginRegistrations) {
    await this.installPlugin(manifest, 'core');

    // Core plugins are always enabled
    if (manifest.tier === 'core') {
      this.enabledPluginIds.add(manifest.id);
    }
  }

  // Activate enabled plugins with 'onStartup' event
  const plugins = loader.getAllPlugins();
  for (const plugin of plugins) {
    if (
      this.enabledPluginIds.has(plugin.info.manifest.id) &&
      plugin.info.manifest.activationEvents.includes('onStartup')
    ) {
      await loader.activatePlugin(plugin.info.manifest.id);
    }
  }

  this.initialized = true;
}

Three steps: register factories, install and enable core plugins, then activate only those with onStartup in their activation events. Plugins without onStartup remain in the enabled state, waiting for their specific activation event to fire.

Phase 3: Activation

When a plugin is activated, the loader creates a sandboxed API instance specifically for that plugin:

async activatePlugin(pluginId: string): Promise<boolean> {
  const loaded = this.loadedPlugins.get(pluginId);
  if (!loaded) return false;
  if (loaded.info.state === 'active') return true;

  loaded.info.state = 'activating';

  try {
    if (loaded.module?.activate) {
      const api = createNotepadXLAPI(pluginId, loaded.context);
      const result = await loaded.module.activate(api, loaded.context);

      if (result && typeof result === 'object' && 'exports' in result) {
        loaded.exports = result.exports;
      }
    }

    loaded.info.state = 'active';
    loaded.info.enabled = true;
    loaded.info.activatedAt = Date.now();
    return true;
  } catch (error) {
    loaded.info.state = 'error';
    return false;
  }
}

Insight

Notice createNotepadXLAPI(pluginId, loaded.context) — the API is created fresh for each plugin. This is the factory pattern at the SDK level. Each plugin gets its own API instance, which means the host can scope settings, track command registrations, and enforce permissions on a per-plugin basis. Plugin A's settings do not leak into Plugin B's namespace.

Here is how that API is constructed:

export function createNotepadXLAPI(
  pluginId: string,
  context: PluginContext
): FullNotepadXLAPI {
  return {
    editor: createEditorAPI(),
    commands: createCommandAPI(pluginId),
    ui: createUIAPI(pluginId),
    files: createFileAPI(pluginId),
    settings: createSettingsAPI(pluginId),
    context,
  };
}

Every sub-API receives the pluginId. When the Find & Replace plugin calls api.settings.get('matchCase', false), the settings API knows to look up notepad-xl.find-replace.matchCase, not just matchCase. The namespacing is automatic and invisible to the plugin author.

Phase 4: Active Life

Once active, the plugin's commands, events, and UI contributions are live. Let's trace what happens when the Find & Replace plugin registers commands:

// Inside the plugin's activate function:
context.subscriptions.push(
  api.commands.registerCommand('findReplace.open', openFind)
);
context.subscriptions.push(
  api.commands.registerCommand('findReplace.findNext', findNext)
);
context.subscriptions.push(
  api.commands.registerCommand('findReplace.findPrevious', findPrevious)
);
context.subscriptions.push(
  api.commands.registerCommand('findReplace.replaceCurrent', replaceCurrent)
);
context.subscriptions.push(
  api.commands.registerCommand('findReplace.replaceAll', replaceAll)
);

Each registerCommand call adds the command to a global registry and returns a Disposable. When that disposable is called, the command is removed from the registry. The plugin does not need to know about the registry. It does not need to call unregisterCommand. It just pushes the disposable and forgets about it.

The same pattern applies to the state change listener that auto-persists settings:

// The plugin's own onStateChange returns a Disposable too:
context.subscriptions.push(
  onStateChange((newState) => {
    api.settings.set('matchCase', newState.matchCase);
    api.settings.set('wrapAround', newState.wrapAround);
  })
);

Even the plugin's internal state management follows the pattern. Here is how Find & Replace implements its own disposable event system:

const stateListeners: ((state: FindReplaceState) => void)[] = [];

export function onStateChange(
  listener: (state: FindReplaceState) => void
): Disposable {
  stateListeners.push(listener);
  return {
    dispose: () => {
      const index = stateListeners.indexOf(listener);
      if (index !== -1) {
        stateListeners.splice(index, 1);
      }
    },
  };
}

The plugin returns a disposable that removes the listener from its array. When the host disposes this subscription, the listener is spliced out. No dangling references. No zombie callbacks.

Phase 5: Deactivation and Disposal

When the plugin is deactivated, two things happen in sequence:

  1. The plugin's deactivate() function is called, giving it a chance to do its own cleanup
  2. All context.subscriptions are disposed by the host

For the Word Count plugin, deactivate() clears its interval and disposes the status bar item:

export function deactivate(): void {
  if (updateInterval) {
    clearInterval(updateInterval);
    updateInterval = null;
  }

  if (statusBarItem) {
    statusBarItem.dispose();
    statusBarItem = null;
  }
}

For Find & Replace, deactivate() resets internal state and clears listeners:

export function deactivate(): void {
  state = {
    isOpen: false,
    showReplace: false,
    findText: '',
    replaceText: '',
    matchCase: false,
    wrapAround: true,
    matchCount: 0,
    currentMatch: 0,
  };
  stateListeners.length = 0;
}

Notice that even if these deactivate() functions did nothing — even if the developer forgot to write them entirely — the subscriptions would still be cleaned up by the host. The deactivate() function is a courtesy, not a requirement. The real cleanup is in the subscription disposal.

The deactivate() function handles what the plugin knows about. The context.subscriptions handles what the host knows about. Together, they are complete.

Comparing: Before and After

The Leaking Way

let handler;
let listener;
let timer;

function activate(api) {
  handler = () => doThing();
  api.commands.register(
    'cmd', handler
  );

  listener = (e) => onEvent(e);
  api.on('change', listener);

  timer = setInterval(poll, 5000);
}

function deactivate(api) {
  api.commands.unregister('cmd');
  api.off('change', listener);
  clearInterval(timer);
  // Forgot about the menu item
  // we added in v2.3...
}

The Disposable Way

function activate(api, ctx) {
  ctx.subscriptions.push(
    api.commands.registerCommand(
      'cmd', () => doThing()
    )
  );

  ctx.subscriptions.push(
    api.editor.onDidChangeDocument(
      (e) => onEvent(e)
    )
  );

  // Even custom timers become
  // disposable:
  const timer = setInterval(
    poll, 5000
  );
  ctx.subscriptions.push({
    dispose: () =>
      clearInterval(timer)
  });
}
// No deactivate needed!

The left side requires the developer to maintain a parallel mental model: for every thing they register, they must write the corresponding unregistration. The right side has one pattern: push into subscriptions. Done.

The Shutdown Sequence

When the entire application shuts down, the lifecycle manager deactivates every active plugin in sequence:

async shutdown(): Promise<void> {
  console.log('Shutting down plugin system...');

  const loader = getPluginLoader();
  const plugins = loader.getAllPlugins();

  // Deactivate all active plugins
  for (const plugin of plugins) {
    if (plugin.info.state === 'active') {
      await loader.deactivatePlugin(plugin.info.manifest.id);
    }
  }

  this.initialized = false;
  console.log('Plugin system shutdown complete');
}

Each deactivation triggers the disposal chain. By the time shutdown is complete, every command has been unregistered, every event listener has been removed, every status bar item has been hidden, and every timer has been cleared. The application exits clean.

What About Plugins from Unknown Authors?

This is where the pattern becomes a security feature, not just a convenience. Community plugins — installed from ZIP files, written by anyone — run the same lifecycle. Their subscriptions are disposed the same way. Even if a community plugin author is careless (or malicious), the host system's disposal of context.subscriptions ensures that deactivation actually deactivates.

A community plugin cannot:

The only thing a plugin could theoretically leak is something it creates entirely outside the API — like a raw setInterval that is never pushed into subscriptions. But since every API interaction returns a Disposable and the pattern encourages pushing into subscriptions, escaping the pattern requires deliberate effort.

The Design Principle

Make the safe path the easiest path. Do not rely on developer discipline. Do not rely on documentation. Make the system structured so that the natural, lazy, path-of-least-resistance way of doing things is also the correct way. The Disposable pattern does not prevent leaks by being strict. It prevents leaks by being convenient.

On-Demand Activation: Only Pay for What You Use

The lifecycle manager also supports on-demand activation for plugins that do not need to start immediately:

async activateOnDemand(pluginId: string, event: string): Promise<boolean> {
  const loader = getPluginLoader();
  const plugin = loader.getPlugin(pluginId);

  if (!plugin) return false;

  // Already active? Nothing to do.
  if (plugin.info.state === 'active') return true;

  // Does this event match the plugin's activation events?
  if (!plugin.info.manifest.activationEvents.includes(event)) {
    return false;
  }

  // Is the plugin enabled?
  if (!this.enabledPluginIds.has(pluginId)) return false;

  return loader.activatePlugin(pluginId);
}

And the broadcast version, for triggering activation across all eligible plugins:

async triggerActivation(event: string): Promise<void> {
  const loader = getPluginLoader();
  const plugins = loader.getAllPlugins();

  for (const plugin of plugins) {
    if (
      plugin.info.state !== 'active' &&
      plugin.info.manifest.activationEvents.includes(event) &&
      this.enabledPluginIds.has(plugin.info.manifest.id)
    ) {
      await loader.activatePlugin(plugin.info.manifest.id);
    }
  }
}

When a user opens a file, the system fires triggerActivation('onFileOpen'). Any plugin that declared onFileOpen in its activation events will be activated at that moment — not at startup, not eagerly, but precisely when needed. Its context.subscriptions array is created fresh, and the full disposal contract applies from that point forward.

Error Handling: Isolation by Default

What happens when a plugin throws during activation? The loader catches it, sets the plugin state to error, fires an error event, and moves on. The rest of the system is unaffected:

try {
  if (loaded.module?.activate) {
    const api = createNotepadXLAPI(pluginId, loaded.context);
    await loaded.module.activate(api, loaded.context);
  }
  loaded.info.state = 'active';
} catch (error) {
  const pluginError: PluginError = {
    code: 'ACTIVATE_ERROR',
    message: `Failed to activate plugin: ${(error as Error).message}`,
    stack: (error as Error).stack,
    timestamp: Date.now(),
  };
  loaded.info.error = pluginError;
  loaded.info.state = 'error';
}

The error is recorded with a timestamp and stack trace for debugging. The plugin enters the error state. Other plugins continue activating. The user sees a text editor that works, minus one misbehaving plugin.

The same isolation applies during deactivation. If a subscription's dispose() throws, it is caught individually:

loaded.context.subscriptions.forEach((sub) => {
  try {
    sub.dispose();
  } catch (e) {
    console.error('Error disposing subscription:', e);
  }
});

One bad disposable does not prevent the rest from being cleaned up. This is not an obvious design decision — many implementations use a simple for loop without try-catch, which means a single throwing disposable can leave the rest undisposed. NotepadXL's approach ensures complete cleanup regardless of individual failures.

User-Installed Plugins: Same Contract, Same Safety

When a user installs a plugin from a ZIP file, it goes through the same lifecycle:

async registerUserPlugin(
  manifest: PluginManifest,
  factory: () => PluginModule
): Promise<PluginInfo | null> {
  const loader = getPluginLoader();

  // Register the factory
  loader.registerCorePlugin(manifest.id, factory);

  // Install the plugin
  const pluginInfo = await this.installPlugin(manifest, 'user-installed');

  if (!pluginInfo) return null;

  // Enable and activate if onStartup
  this.enabledPluginIds.add(manifest.id);
  this.saveEnabledPlugins();

  if (manifest.activationEvents?.includes('onStartup')) {
    await loader.activatePlugin(manifest.id);
  }

  return pluginInfo;
}

The user plugin gets its own PluginContext with its own subscriptions array. It gets its own scoped API instance. It follows the same activation events system. And when disabled, it goes through the same disposal chain.

The enabled plugin list is persisted to localStorage, so user preferences survive application restarts:

private saveEnabledPlugins(): void {
  if (typeof localStorage === 'undefined') return;

  try {
    localStorage.setItem(
      'notepad-xl-enabled-plugins',
      JSON.stringify(Array.from(this.enabledPluginIds))
    );
  } catch (error) {
    console.error('Failed to save enabled plugins:', error);
  }
}

Lessons for Your Own Projects

You do not need to be building a text editor to benefit from this pattern. The principles apply to any system with pluggable components:

  1. Every registration should return a Disposable. Commands, event listeners, UI elements, timers — if it can be created, it can be un-created, and the un-creation should be packaged as a Disposable.
  2. Provide a collection point. Give your plugin authors a subscriptions array (or similar) where they can dump their disposables. Clean it up for them when the time comes.
  3. Wrap disposals in try-catch. Never let one failing cleanup prevent others from running.
  4. Reset the collection after disposal. Set the array to empty. Do not let stale references survive across activation cycles.
  5. Scope APIs per plugin. Use factory functions that accept a plugin ID. Namespace settings, track registrations, enforce permissions — all scoped to the individual plugin.

Insight

The Disposable pattern is not about being clever. It is about removing the possibility of a specific class of bugs. Memory leaks are not fixed by "being more careful." They are fixed by making carelessness safe. That is the difference between a pattern and a policy.

What's Next

This post covered how NotepadXL's lifecycle and disposal system keeps plugins safe and the application leak-free. In Part 3: Write Once, Deploy Everywhere, we will look at the other major architectural challenge: how one React/TypeScript codebase compiles to Tauri desktop, Electron desktop, plain web, and Capacitor mobile — and the platform.ts abstraction layer that makes file I/O, clipboard, and dialogs work identically across all of them.

NotepadXL Architecture Series

  1. Part 1: Why Every App Should Be a Plugin System
  2. Part 2: The Disposable Pattern — Zero Memory Leaks by Design (this post)
  3. Part 3: Write Once, Deploy Everywhere — One Codebase, Four Platforms

This is part of my daily developer log. Follow my journey as I learn new skills and build tools with Brian at Actyra.

← Back to all posts