From cf31029350820c6bfc0fbd0e052a79a067ee6116 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 22 Jun 2026 09:04:29 -0400 Subject: [PATCH] fix(core): batch plugin shutdown --- packages/core/src/plugin.ts | 14 ++++++++++--- packages/core/test/plugin.test.ts | 34 +++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index c826ba513b..0a02ddfa7f 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -103,8 +103,16 @@ export const layer = Layer.effect( } }[keyof Hooks][] = [] const events = yield* EventV2.Service - const scope = yield* Scope.Scope const locks = KeyedMutex.makeUnsafe() + const scope = yield* Scope.make() + + // One registry-owned scope lets shutdown remove every plugin transform in one batch. + yield* Effect.addFinalizer((exit) => + Effect.gen(function* () { + hooks = [] + yield* State.batch(Scope.close(scope, exit)) + }), + ) const svc = Service.of({ add: Effect.fn("Plugin.add")(function* (input) { @@ -112,7 +120,7 @@ export const layer = Layer.effect( yield* locks.withLock(id)( Effect.gen(function* () { const existing = hooks.find((item) => item.id === id) - if (existing) yield* Scope.close(existing.scope, Exit.void).pipe(Effect.ignore) + if (existing) yield* State.batch(Scope.close(existing.scope, Exit.void)).pipe(Effect.ignore) const childScope = yield* Scope.fork(scope) const result = yield* input.effect.pipe( Scope.provide(childScope), @@ -181,7 +189,7 @@ export const layer = Layer.effect( Effect.gen(function* () { const existing = hooks.find((item) => item.id === id) hooks = hooks.filter((item) => item.id !== id) - if (existing) yield* Scope.close(existing.scope, Exit.void).pipe(Effect.ignore) + if (existing) yield* State.batch(Scope.close(existing.scope, Exit.void)).pipe(Effect.ignore) }), ) }), diff --git a/packages/core/test/plugin.test.ts b/packages/core/test/plugin.test.ts index 69b63683c7..d8fe74336b 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -46,6 +46,40 @@ describe("PluginV2", () => { }), ) + it.effect("batches plugin state rebuilds when the registry layer finalizes", () => + Effect.gen(function* () { + let finalized = 0 + const values = State.create({ + initial: () => ({ values: [] as string[] }), + draft: (draft) => ({ add: (value: string) => draft.values.push(value) }), + finalize: () => Effect.sync(() => finalized++), + }) + const layerScope = yield* Scope.fork(yield* Scope.Scope) + const plugin = Context.get(yield* Layer.buildWithScope(Layer.fresh(plugins), layerScope), PluginV2.Service) + + yield* State.batch( + Effect.forEach( + ["first", "second"], + (id) => + plugin.add({ + id: PluginV2.ID.make(id), + effect: values + .transform((editor) => { + editor.add(id) + }) + .pipe(Effect.asVoid), + }), + { discard: true }, + ), + ) + finalized = 0 + + yield* Scope.close(layerScope, Exit.void) + expect(values.get().values).toEqual([]) + expect(finalized).toBe(1) + }), + ) + it.effect("serializes same-ID additions and leaves one removable attachment", () => Effect.gen(function* () { const values = state()