From 6d96fbe005199d9622739b86513b908f1a3b76d7 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Fri, 19 Sep 2025 22:51:28 +0800 Subject: [PATCH 1/5] feat(effectScope): add lazy-initialized `signal` getter with automatic abort on stop --- .../reactivity/__tests__/effectScope.spec.ts | 17 +++++++++++++++++ packages/reactivity/src/effectScope.ts | 13 +++++++++++++ 2 files changed, 30 insertions(+) diff --git a/packages/reactivity/__tests__/effectScope.spec.ts b/packages/reactivity/__tests__/effectScope.spec.ts index 93ee648e2df..08b4ac76652 100644 --- a/packages/reactivity/__tests__/effectScope.spec.ts +++ b/packages/reactivity/__tests__/effectScope.spec.ts @@ -363,6 +363,23 @@ describe('reactivity/effect/scope', () => { expect(getEffectsCount(scope)).toBe(0) expect(scope.cleanupsLength).toBe(0) }) + + test('signal', () => { + const scope = effectScope() + // should not create an `AbortController` until `scope.signal` is accessed + expect((scope as any)._controller).toBeUndefined() + + const { signal } = scope + expect((scope as any)._controller).toBeDefined() + expect(signal).toBeDefined() + + const spy = vi.fn() + signal.addEventListener('abort', spy) + + scope.stop() + // should trigger `abort` on the `signal` when `scope.stop()` is called. + expect(spy).toBeCalled() + }) }) function getEffectsCount(scope: EffectScope): number { diff --git a/packages/reactivity/src/effectScope.ts b/packages/reactivity/src/effectScope.ts index 36c9b85e8d7..4bf109ed40a 100644 --- a/packages/reactivity/src/effectScope.ts +++ b/packages/reactivity/src/effectScope.ts @@ -19,6 +19,16 @@ export class EffectScope implements ReactiveNode { * @internal */ cleanupsLength = 0 + /** + * @internal + */ + private _controller: AbortController | undefined + + get signal(): AbortSignal { + if (!this._controller) this._controller = new AbortController() + + return this._controller.signal + } constructor(detached = false) { if (!detached && activeEffectScope) { @@ -72,6 +82,9 @@ export class EffectScope implements ReactiveNode { if (!this.active) { return } + if (this._controller) { + this._controller.abort() + } this.flags = EffectFlags.STOP let dep = this.deps while (dep !== undefined) { From fe877b95605c4e1ffcc9f3c4ab1bf77caded54af Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Tue, 23 Sep 2025 14:51:54 +0800 Subject: [PATCH 2/5] chore: move abort to last --- packages/reactivity/src/effectScope.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/reactivity/src/effectScope.ts b/packages/reactivity/src/effectScope.ts index 4bf109ed40a..905c52980af 100644 --- a/packages/reactivity/src/effectScope.ts +++ b/packages/reactivity/src/effectScope.ts @@ -82,9 +82,6 @@ export class EffectScope implements ReactiveNode { if (!this.active) { return } - if (this._controller) { - this._controller.abort() - } this.flags = EffectFlags.STOP let dep = this.deps while (dep !== undefined) { @@ -101,6 +98,9 @@ export class EffectScope implements ReactiveNode { unlink(sub) } cleanup(this) + if (this._controller) { + this._controller.abort() + } } } From 5efe29626b7ae2190cf070d8ac0e040c2a4a4f3f Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Sun, 28 Sep 2025 00:30:25 +0800 Subject: [PATCH 3/5] perf(effectScope): replace cleanups array with abort listener --- packages/reactivity/__tests__/effectScope.spec.ts | 2 +- packages/reactivity/src/effectScope.ts | 13 ++----------- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/packages/reactivity/__tests__/effectScope.spec.ts b/packages/reactivity/__tests__/effectScope.spec.ts index 08b4ac76652..b3b00566363 100644 --- a/packages/reactivity/__tests__/effectScope.spec.ts +++ b/packages/reactivity/__tests__/effectScope.spec.ts @@ -361,7 +361,7 @@ describe('reactivity/effect/scope', () => { expect(cleanupCalls).toBe(1) expect(getEffectsCount(scope)).toBe(0) - expect(scope.cleanupsLength).toBe(0) + expect(scope.signal.aborted).toBe(true) }) test('signal', () => { diff --git a/packages/reactivity/src/effectScope.ts b/packages/reactivity/src/effectScope.ts index 905c52980af..fac17dabe98 100644 --- a/packages/reactivity/src/effectScope.ts +++ b/packages/reactivity/src/effectScope.ts @@ -1,4 +1,4 @@ -import { EffectFlags, cleanup } from './effect' +import { EffectFlags } from './effect' import { type Link, type ReactiveNode, link, unlink } from './system' import { warn } from './warning' @@ -11,14 +11,6 @@ export class EffectScope implements ReactiveNode { subsTail: Link | undefined = undefined flags: number = 0 - /** - * @internal - */ - cleanups: (() => void)[] = [] - /** - * @internal - */ - cleanupsLength = 0 /** * @internal */ @@ -97,7 +89,6 @@ export class EffectScope implements ReactiveNode { if (sub !== undefined) { unlink(sub) } - cleanup(this) if (this._controller) { this._controller.abort() } @@ -143,7 +134,7 @@ export function setCurrentScope(scope?: EffectScope): EffectScope | undefined { */ export function onScopeDispose(fn: () => void, failSilently = false): void { if (activeEffectScope !== undefined) { - activeEffectScope.cleanups[activeEffectScope.cleanupsLength++] = fn + activeEffectScope.signal.addEventListener('abort', fn, { once: true }) } else if (__DEV__ && !failSilently) { warn( `onScopeDispose() is called when there is no active effect scope` + From 525002087128143e9687db1166b4c13648b40d77 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Sun, 28 Sep 2025 22:36:53 +0800 Subject: [PATCH 4/5] chore: add effectScope benchmark --- .../__benchmarks__/effectScope.bench.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 packages/reactivity/__benchmarks__/effectScope.bench.ts diff --git a/packages/reactivity/__benchmarks__/effectScope.bench.ts b/packages/reactivity/__benchmarks__/effectScope.bench.ts new file mode 100644 index 00000000000..298b1336dd3 --- /dev/null +++ b/packages/reactivity/__benchmarks__/effectScope.bench.ts @@ -0,0 +1,26 @@ +import { bench, describe } from 'vitest' +import { + effectScope, + onEffectCleanup, +} from '../dist/reactivity.esm-browser.prod' + +describe('effectScope', () => { + function benchEffectCreateAndStop(size: number) { + bench( + `create and stop an effectScope that tracks ${size} cleanup functions`, + () => { + const scope = effectScope() + scope.run(() => { + for (let i = 0; i < size; i++) { + onEffectCleanup(() => {}) + } + }) + scope.stop() + }, + ) + } + + benchEffectCreateAndStop(10) + benchEffectCreateAndStop(100) + benchEffectCreateAndStop(1000) +}) From 38f76c52ec18986ef865e3daaae8392156fbeb00 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Sun, 28 Sep 2025 22:51:32 +0800 Subject: [PATCH 5/5] chore: update test --- packages/reactivity/__tests__/effectScope.spec.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/reactivity/__tests__/effectScope.spec.ts b/packages/reactivity/__tests__/effectScope.spec.ts index b3b00566363..38b8c59828a 100644 --- a/packages/reactivity/__tests__/effectScope.spec.ts +++ b/packages/reactivity/__tests__/effectScope.spec.ts @@ -194,6 +194,8 @@ describe('reactivity/effect/scope', () => { onScopeDispose(() => (dummy += 2)) }) + expect(scope.signal.aborted).toBe(false) + scope.run(() => { onScopeDispose(() => (dummy += 4)) }) @@ -202,6 +204,7 @@ describe('reactivity/effect/scope', () => { scope.stop() expect(dummy).toBe(7) + expect(scope.signal.aborted).toBe(true) }) it('should warn onScopeDispose() is called when there is no active effect scope', () => { @@ -361,7 +364,6 @@ describe('reactivity/effect/scope', () => { expect(cleanupCalls).toBe(1) expect(getEffectsCount(scope)).toBe(0) - expect(scope.signal.aborted).toBe(true) }) test('signal', () => { @@ -379,6 +381,7 @@ describe('reactivity/effect/scope', () => { scope.stop() // should trigger `abort` on the `signal` when `scope.stop()` is called. expect(spy).toBeCalled() + expect(scope.signal.aborted).toBe(true) }) })