From adf44cd75342e3ce20a96dc79150d477f4caf436 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Atienza=20L=C3=B3pez?= <2365331+datienzalopez@users.noreply.github.com> Date: Wed, 8 Apr 2026 20:41:03 +0100 Subject: [PATCH] fix: fire plugin:activate and plugin:deactivate from setPluginStatus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setPluginStatus rebuilt the hook pipeline but never called the lifecycle hooks, so plugin:activate and plugin:deactivate were silently skipped whenever a plugin was enabled or disabled via the admin UI. Now plugin:deactivate fires on the current pipeline (while the plugin is still registered) before it is removed, and plugin:activate fires after the pipeline is rebuilt with the plugin included — matching the sequence used by PluginManager.activate/deactivate. Co-Authored-By: Claude Sonnet 4.6 --- ...x-plugin-activate-deactivate-not-called.md | 5 ++ packages/core/src/emdash-runtime.ts | 7 +- .../unit/plugins/pipeline-rebuild.test.ts | 64 +++++++++++++++++++ 3 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 .changeset/fix-plugin-activate-deactivate-not-called.md diff --git a/.changeset/fix-plugin-activate-deactivate-not-called.md b/.changeset/fix-plugin-activate-deactivate-not-called.md new file mode 100644 index 000000000..4c3cc1e6d --- /dev/null +++ b/.changeset/fix-plugin-activate-deactivate-not-called.md @@ -0,0 +1,5 @@ +--- +"emdash": patch +--- + +Fixes `plugin:activate` and `plugin:deactivate` hooks not being called when enabling or disabling a plugin via the admin UI or `setPluginStatus`. Previously, `setPluginStatus` rebuilt the hook pipeline but never invoked the lifecycle hooks. Now `plugin:activate` fires after the pipeline is rebuilt with the plugin included, and `plugin:deactivate` fires on the current pipeline before the plugin is removed. diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index 52c78e484..3235a1a7a 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -400,11 +400,14 @@ export class EmDashRuntime { this.pluginStates.set(pluginId, status); if (status === "active") { this.enabledPlugins.add(pluginId); + await this.rebuildHookPipeline(); + await this._hooks.runPluginActivate(pluginId); } else { + // Fire deactivate on the current pipeline while the plugin is still in it + await this._hooks.runPluginDeactivate(pluginId); this.enabledPlugins.delete(pluginId); + await this.rebuildHookPipeline(); } - - await this.rebuildHookPipeline(); } /** diff --git a/packages/core/tests/unit/plugins/pipeline-rebuild.test.ts b/packages/core/tests/unit/plugins/pipeline-rebuild.test.ts index 3322f7c4f..53dba3abb 100644 --- a/packages/core/tests/unit/plugins/pipeline-rebuild.test.ts +++ b/packages/core/tests/unit/plugins/pipeline-rebuild.test.ts @@ -265,4 +265,68 @@ describe("HookPipeline rebuild on plugin disable/enable (#105)", () => { expect(pipeline2.hasHooks("plugin:install")).toBe(false); expect(pipeline2.hasHooks("plugin:activate")).toBe(false); }); + + it("plugin:activate fires when runPluginActivate is called after pipeline rebuild", async () => { + const activateHandler = vi.fn().mockResolvedValue(undefined); + + const plugin = createTestPlugin({ + id: "my-plugin", + hooks: { + "plugin:activate": createTestHook("my-plugin", activateHandler), + }, + }); + + // Simulate enabling: rebuild pipeline with plugin included, then invoke activate + const pipeline = createHookPipeline([plugin], { db }); + await pipeline.runPluginActivate("my-plugin"); + + expect(activateHandler).toHaveBeenCalledTimes(1); + }); + + it("plugin:deactivate fires when runPluginDeactivate is called before pipeline rebuild", async () => { + const deactivateHandler = vi.fn().mockResolvedValue(undefined); + + const plugin = createTestPlugin({ + id: "my-plugin", + hooks: { + "plugin:deactivate": createTestHook("my-plugin", deactivateHandler), + }, + }); + + // Simulate disabling: invoke deactivate on the current pipeline, then rebuild without the plugin + const pipeline = createHookPipeline([plugin], { db }); + await pipeline.runPluginDeactivate("my-plugin"); + + expect(deactivateHandler).toHaveBeenCalledTimes(1); + + // Rebuild without the plugin — hook should no longer be registered + const disabledPipeline = createHookPipeline([], { db }); + expect(disabledPipeline.hasHooks("plugin:deactivate")).toBe(false); + }); + + it("plugin:activate only fires for the targeted plugin, not others", async () => { + const activateA = vi.fn().mockResolvedValue(undefined); + const activateB = vi.fn().mockResolvedValue(undefined); + + const pluginA = createTestPlugin({ + id: "plugin-a", + hooks: { + "plugin:activate": createTestHook("plugin-a", activateA), + }, + }); + const pluginB = createTestPlugin({ + id: "plugin-b", + hooks: { + "plugin:activate": createTestHook("plugin-b", activateB), + }, + }); + + const pipeline = createHookPipeline([pluginA, pluginB], { db }); + + // Enabling only plugin-a should not fire plugin-b's activate + await pipeline.runPluginActivate("plugin-a"); + + expect(activateA).toHaveBeenCalledTimes(1); + expect(activateB).not.toHaveBeenCalled(); + }); });