From 8757e5b44bd670f6ded7ecaeb1e3458f31f53242 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 08:15:23 +0000 Subject: [PATCH] Track effect subscriptions and restore tracking context in reactive core Co-Authored-By: Shotaro Nakamura --- packages/eclipsa/core/reactive/core.test.ts | 50 +++++++++++++++++++++ packages/eclipsa/core/reactive/core.ts | 30 +++++++++---- 2 files changed, 71 insertions(+), 9 deletions(-) diff --git a/packages/eclipsa/core/reactive/core.test.ts b/packages/eclipsa/core/reactive/core.test.ts index 4726665..7c9cce1 100644 --- a/packages/eclipsa/core/reactive/core.test.ts +++ b/packages/eclipsa/core/reactive/core.test.ts @@ -53,4 +53,54 @@ describe('signal', () => { expect(cb).toHaveBeenNthCalledWith(2, '1:new') expect(cb).toHaveBeenNthCalledWith(3, '2:new') }) + + it('records read signals on the effect and unsubscribes them on dispose', () => { + const count = signal(0) + const cb = vi.fn() + + const dispose = effect(() => { + cb(count.get()) + }) + + expect(count.effects.size).toBe(1) + const [tracked] = count.effects + expect(tracked?.signals.has(count)).toBe(true) + + dispose() + + expect(count.effects.size).toBe(0) + count.set(1) + expect(cb).toHaveBeenCalledTimes(1) + }) + + it('restores the previous tracking context when an effect body throws', () => { + const count = signal(0) + + expect(() => + effect(() => { + throw new Error('boom') + }), + ).toThrow('boom') + + count.get() + expect(count.effects.size).toBe(0) + }) + + it('keeps tracking the outer effect after a nested effect finishes', () => { + const outer = signal(0) + const inner = signal(0) + const outerCb = vi.fn() + + effect(() => { + effect(() => { + inner.get() + }) + outerCb(outer.get()) + }) + + expect(outerCb).toHaveBeenCalledTimes(1) + outer.set(1) + expect(outerCb).toHaveBeenCalledTimes(2) + expect(outerCb).toHaveBeenNthCalledWith(2, 1) + }) }) diff --git a/packages/eclipsa/core/reactive/core.ts b/packages/eclipsa/core/reactive/core.ts index 0fbbf36..5195ebb 100644 --- a/packages/eclipsa/core/reactive/core.ts +++ b/packages/eclipsa/core/reactive/core.ts @@ -27,6 +27,7 @@ export const signal = (init: T): Signal => { get() { if (currentEffect) { this.effects.add(currentEffect) + currentEffect.signals.add(this) } return value }, @@ -45,23 +46,34 @@ export const signal = (init: T): Signal => { return signal } -const registry = new FinalizationRegistry((effect) => { +const unsubscribeEffect = (effect: Effect) => { for (const signal of effect.signals) { signal.effects.delete(effect) } -}) + effect.signals.clear() +} + +const registry = new FinalizationRegistry(unsubscribeEffect) -export const effect = (fn: () => void) => { +export const effect = (fn: () => void): (() => void) => { const newEffect: Effect = { - fn() { - fn() - }, + fn, signals: new Set(), } + const previousEffect = currentEffect currentEffect = newEffect - newEffect.fn() - currentEffect = null + try { + newEffect.fn() + } finally { + currentEffect = previousEffect + } + + const dispose = () => { + registry.unregister(dispose) + unsubscribeEffect(newEffect) + } + registry.register(dispose, newEffect, dispose) - registry.register(fn, newEffect) + return dispose }