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) +}) diff --git a/packages/reactivity/__tests__/effectScope.spec.ts b/packages/reactivity/__tests__/effectScope.spec.ts index 93ee648e2df..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,24 @@ describe('reactivity/effect/scope', () => { expect(cleanupCalls).toBe(1) 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() + expect(scope.signal.aborted).toBe(true) }) }) diff --git a/packages/reactivity/src/effectScope.ts b/packages/reactivity/src/effectScope.ts index 36c9b85e8d7..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' @@ -14,11 +14,13 @@ export class EffectScope implements ReactiveNode { /** * @internal */ - cleanups: (() => void)[] = [] - /** - * @internal - */ - cleanupsLength = 0 + private _controller: AbortController | undefined + + get signal(): AbortSignal { + if (!this._controller) this._controller = new AbortController() + + return this._controller.signal + } constructor(detached = false) { if (!detached && activeEffectScope) { @@ -87,7 +89,9 @@ export class EffectScope implements ReactiveNode { if (sub !== undefined) { unlink(sub) } - cleanup(this) + if (this._controller) { + this._controller.abort() + } } } @@ -130,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` +