From 05eb4e0fefd585125dd60b7f8fe9c36928d921aa Mon Sep 17 00:00:00 2001 From: Evan You Date: Sun, 25 Feb 2024 16:51:49 +0800 Subject: [PATCH] Refactor reactivity system to use version counting and doubly-linked list tracking (#10397) Bug fixes close #10236 close #10069 PRs made stale by this one close #10290 close #10354 close #10189 close #9480 --- .../__benchmarks__/computed.bench.ts | 200 +++++++ .../reactivity/__benchmarks__/effect.bench.ts | 111 ++++ .../reactiveArray.bench.ts | 8 +- .../reactiveMap.bench.ts | 4 +- .../__benchmarks__/reactiveObject.bench.ts | 21 + .../ref.bench.ts | 1 - .../reactivity/__tests__/computed.bench.ts | 126 ---- .../reactivity/__tests__/computed.spec.ts | 396 ++++++++++--- .../__tests__/deferredComputed.spec.ts | 155 ----- packages/reactivity/__tests__/effect.spec.ts | 105 ++-- .../reactivity/__tests__/effectScope.spec.ts | 4 +- packages/reactivity/__tests__/gc.spec.ts | 2 +- .../__tests__/reactiveObject.bench.ts | 114 ---- .../reactivity/__tests__/readonly.spec.ts | 2 +- packages/reactivity/__tests__/ref.spec.ts | 11 + .../__tests__/shallowReactive.spec.ts | 1 + packages/reactivity/src/baseHandlers.ts | 22 +- packages/reactivity/src/collectionHandlers.ts | 7 +- packages/reactivity/src/computed.ts | 152 ++--- packages/reactivity/src/deferredComputed.ts | 6 - packages/reactivity/src/dep.ts | 302 +++++++++- packages/reactivity/src/effect.ts | 549 +++++++++++------- packages/reactivity/src/index.ts | 6 +- packages/reactivity/src/reactiveEffect.ts | 150 ----- packages/reactivity/src/ref.ts | 130 ++--- .../__tests__/apiSetupHelpers.spec.ts | 17 +- .../runtime-core/__tests__/apiWatch.spec.ts | 45 +- .../runtime-core/src/apiAsyncComponent.ts | 1 - packages/runtime-core/src/apiWatch.ts | 16 +- packages/runtime-core/src/component.ts | 9 +- .../src/componentPublicInstance.ts | 1 - .../src/components/BaseTransition.ts | 3 +- packages/runtime-core/src/customFormatter.ts | 3 +- packages/runtime-core/src/hmr.ts | 2 - packages/runtime-core/src/renderer.ts | 37 +- packages/runtime-core/src/scheduler.ts | 1 - .../__tests__/ssrComputed.spec.ts | 22 +- packages/server-renderer/src/render.ts | 9 - 38 files changed, 1629 insertions(+), 1122 deletions(-) create mode 100644 packages/reactivity/__benchmarks__/computed.bench.ts create mode 100644 packages/reactivity/__benchmarks__/effect.bench.ts rename packages/reactivity/{__tests__ => __benchmarks__}/reactiveArray.bench.ts (94%) rename packages/reactivity/{__tests__ => __benchmarks__}/reactiveMap.bench.ts (97%) create mode 100644 packages/reactivity/__benchmarks__/reactiveObject.bench.ts rename packages/reactivity/{__tests__ => __benchmarks__}/ref.bench.ts (99%) delete mode 100644 packages/reactivity/__tests__/computed.bench.ts delete mode 100644 packages/reactivity/__tests__/deferredComputed.spec.ts delete mode 100644 packages/reactivity/__tests__/reactiveObject.bench.ts delete mode 100644 packages/reactivity/src/deferredComputed.ts delete mode 100644 packages/reactivity/src/reactiveEffect.ts diff --git a/packages/reactivity/__benchmarks__/computed.bench.ts b/packages/reactivity/__benchmarks__/computed.bench.ts new file mode 100644 index 00000000000..d9757501f81 --- /dev/null +++ b/packages/reactivity/__benchmarks__/computed.bench.ts @@ -0,0 +1,200 @@ +import { bench, describe } from 'vitest' +import { type ComputedRef, type Ref, computed, effect, ref } from '../src' + +describe('computed', () => { + bench('create computed', () => { + computed(() => 100) + }) + + { + const v = ref(100) + computed(() => v.value * 2) + let i = 0 + bench("write ref, don't read computed (without effect)", () => { + v.value = i++ + }) + } + + { + const v = ref(100) + const c = computed(() => { + return v.value * 2 + }) + effect(() => c.value) + let i = 0 + bench("write ref, don't read computed (with effect)", () => { + v.value = i++ + }) + } + + { + const v = ref(100) + const c = computed(() => { + return v.value * 2 + }) + let i = 0 + bench('write ref, read computed (without effect)', () => { + v.value = i++ + c.value + }) + } + + { + const v = ref(100) + const c = computed(() => { + return v.value * 2 + }) + effect(() => c.value) + let i = 0 + bench('write ref, read computed (with effect)', () => { + v.value = i++ + c.value + }) + } + + { + const v = ref(100) + const computeds: ComputedRef[] = [] + for (let i = 0, n = 1000; i < n; i++) { + const c = computed(() => { + return v.value * 2 + }) + computeds.push(c) + } + let i = 0 + bench("write ref, don't read 1000 computeds (without effect)", () => { + v.value = i++ + }) + } + + { + const v = ref(100) + const computeds: ComputedRef[] = [] + for (let i = 0, n = 1000; i < n; i++) { + const c = computed(() => { + return v.value * 2 + }) + effect(() => c.value) + computeds.push(c) + } + let i = 0 + bench( + "write ref, don't read 1000 computeds (with multiple effects)", + () => { + v.value = i++ + }, + ) + } + + { + const v = ref(100) + const computeds: ComputedRef[] = [] + for (let i = 0, n = 1000; i < n; i++) { + const c = computed(() => { + return v.value * 2 + }) + computeds.push(c) + } + effect(() => { + for (let i = 0; i < 1000; i++) { + computeds[i].value + } + }) + let i = 0 + bench("write ref, don't read 1000 computeds (with single effect)", () => { + v.value = i++ + }) + } + + { + const v = ref(100) + const computeds: ComputedRef[] = [] + for (let i = 0, n = 1000; i < n; i++) { + const c = computed(() => { + return v.value * 2 + }) + computeds.push(c) + } + let i = 0 + bench('write ref, read 1000 computeds (no effect)', () => { + v.value = i++ + computeds.forEach(c => c.value) + }) + } + + { + const v = ref(100) + const computeds: ComputedRef[] = [] + for (let i = 0, n = 1000; i < n; i++) { + const c = computed(() => { + return v.value * 2 + }) + effect(() => c.value) + computeds.push(c) + } + let i = 0 + bench('write ref, read 1000 computeds (with multiple effects)', () => { + v.value = i++ + computeds.forEach(c => c.value) + }) + } + + { + const v = ref(100) + const computeds: ComputedRef[] = [] + for (let i = 0, n = 1000; i < n; i++) { + const c = computed(() => { + return v.value * 2 + }) + effect(() => c.value) + computeds.push(c) + } + effect(() => { + for (let i = 0; i < 1000; i++) { + computeds[i].value + } + }) + let i = 0 + bench('write ref, read 1000 computeds (with single effect)', () => { + v.value = i++ + computeds.forEach(c => c.value) + }) + } + + { + const refs: Ref[] = [] + for (let i = 0, n = 1000; i < n; i++) { + refs.push(ref(i)) + } + const c = computed(() => { + let total = 0 + refs.forEach(ref => (total += ref.value)) + return total + }) + let i = 0 + const n = refs.length + bench('1000 refs, read 1 computed (without effect)', () => { + refs[i++ % n].value++ + c.value + }) + } + + { + const refs: Ref[] = [] + for (let i = 0, n = 1000; i < n; i++) { + refs.push(ref(i)) + } + const c = computed(() => { + let total = 0 + refs.forEach(ref => (total += ref.value)) + return total + }) + effect(() => c.value) + let i = 0 + const n = refs.length + bench('1000 refs, read 1 computed (with effect)', () => { + refs[i++ % n].value++ + c.value + }) + } +}) diff --git a/packages/reactivity/__benchmarks__/effect.bench.ts b/packages/reactivity/__benchmarks__/effect.bench.ts new file mode 100644 index 00000000000..8d3d6ecfbfc --- /dev/null +++ b/packages/reactivity/__benchmarks__/effect.bench.ts @@ -0,0 +1,111 @@ +import { bench, describe } from 'vitest' +import { type Ref, effect, ref } from '../src' + +describe('effect', () => { + { + let i = 0 + const n = ref(0) + effect(() => n.value) + bench('single ref invoke', () => { + n.value = i++ + }) + } + + function benchEffectCreate(size: number) { + bench(`create an effect that tracks ${size} refs`, () => { + const refs: Ref[] = [] + for (let i = 0; i < size; i++) { + refs.push(ref(i)) + } + effect(() => { + for (let i = 0; i < size; i++) { + refs[i].value + } + }) + }) + } + + benchEffectCreate(1) + benchEffectCreate(10) + benchEffectCreate(100) + benchEffectCreate(1000) + + function benchEffectCreateAndStop(size: number) { + bench(`create and stop an effect that tracks ${size} refs`, () => { + const refs: Ref[] = [] + for (let i = 0; i < size; i++) { + refs.push(ref(i)) + } + const e = effect(() => { + for (let i = 0; i < size; i++) { + refs[i].value + } + }) + e.effect.stop() + }) + } + + benchEffectCreateAndStop(1) + benchEffectCreateAndStop(10) + benchEffectCreateAndStop(100) + benchEffectCreateAndStop(1000) + + function benchWithRefs(size: number) { + let j = 0 + const refs: Ref[] = [] + for (let i = 0; i < size; i++) { + refs.push(ref(i)) + } + effect(() => { + for (let i = 0; i < size; i++) { + refs[i].value + } + }) + bench(`1 effect, mutate ${size} refs`, () => { + for (let i = 0; i < size; i++) { + refs[i].value = i + j++ + } + }) + } + + benchWithRefs(10) + benchWithRefs(100) + benchWithRefs(1000) + + function benchWithBranches(size: number) { + const toggle = ref(true) + const refs: Ref[] = [] + for (let i = 0; i < size; i++) { + refs.push(ref(i)) + } + effect(() => { + if (toggle.value) { + for (let i = 0; i < size; i++) { + refs[i].value + } + } + }) + bench(`${size} refs branch toggle`, () => { + toggle.value = !toggle.value + }) + } + + benchWithBranches(10) + benchWithBranches(100) + benchWithBranches(1000) + + function benchMultipleEffects(size: number) { + let i = 0 + const n = ref(0) + for (let i = 0; i < size; i++) { + effect(() => n.value) + } + bench(`1 ref invoking ${size} effects`, () => { + n.value = i++ + }) + } + + benchMultipleEffects(10) + benchMultipleEffects(100) + benchMultipleEffects(1000) +}) diff --git a/packages/reactivity/__tests__/reactiveArray.bench.ts b/packages/reactivity/__benchmarks__/reactiveArray.bench.ts similarity index 94% rename from packages/reactivity/__tests__/reactiveArray.bench.ts rename to packages/reactivity/__benchmarks__/reactiveArray.bench.ts index 9ce0dc531d1..6726cccfd89 100644 --- a/packages/reactivity/__tests__/reactiveArray.bench.ts +++ b/packages/reactivity/__benchmarks__/reactiveArray.bench.ts @@ -3,7 +3,7 @@ import { computed, reactive, readonly, shallowRef, triggerRef } from '../src' for (let amount = 1e1; amount < 1e4; amount *= 10) { { - const rawArray = [] + const rawArray: any[] = [] for (let i = 0, n = amount; i < n; i++) { rawArray.push(i) } @@ -21,7 +21,7 @@ for (let amount = 1e1; amount < 1e4; amount *= 10) { } { - const rawArray = [] + const rawArray: any[] = [] for (let i = 0, n = amount; i < n; i++) { rawArray.push(i) } @@ -40,7 +40,7 @@ for (let amount = 1e1; amount < 1e4; amount *= 10) { } { - const rawArray = [] + const rawArray: any[] = [] for (let i = 0, n = amount; i < n; i++) { rawArray.push(i) } @@ -56,7 +56,7 @@ for (let amount = 1e1; amount < 1e4; amount *= 10) { } { - const rawArray = [] + const rawArray: any[] = [] for (let i = 0, n = amount; i < n; i++) { rawArray.push(i) } diff --git a/packages/reactivity/__tests__/reactiveMap.bench.ts b/packages/reactivity/__benchmarks__/reactiveMap.bench.ts similarity index 97% rename from packages/reactivity/__tests__/reactiveMap.bench.ts rename to packages/reactivity/__benchmarks__/reactiveMap.bench.ts index 70a034e96c3..f8b4611153e 100644 --- a/packages/reactivity/__tests__/reactiveMap.bench.ts +++ b/packages/reactivity/__benchmarks__/reactiveMap.bench.ts @@ -79,7 +79,7 @@ bench('create reactive map', () => { { const r = reactive(createMap({ a: 1 })) - const computeds = [] + const computeds: any[] = [] for (let i = 0, n = 1000; i < n; i++) { const c = computed(() => { return r.get('a') * 2 @@ -94,7 +94,7 @@ bench('create reactive map', () => { { const r = reactive(createMap({ a: 1 })) - const computeds = [] + const computeds: any[] = [] for (let i = 0, n = 1000; i < n; i++) { const c = computed(() => { return r.get('a') * 2 diff --git a/packages/reactivity/__benchmarks__/reactiveObject.bench.ts b/packages/reactivity/__benchmarks__/reactiveObject.bench.ts new file mode 100644 index 00000000000..a326a111b49 --- /dev/null +++ b/packages/reactivity/__benchmarks__/reactiveObject.bench.ts @@ -0,0 +1,21 @@ +import { bench } from 'vitest' +import { reactive } from '../src' + +bench('create reactive obj', () => { + reactive({ a: 1 }) +}) + +{ + const r = reactive({ a: 1 }) + bench('read reactive obj property', () => { + r.a + }) +} + +{ + let i = 0 + const r = reactive({ a: 1 }) + bench('write reactive obj property', () => { + r.a = i++ + }) +} diff --git a/packages/reactivity/__tests__/ref.bench.ts b/packages/reactivity/__benchmarks__/ref.bench.ts similarity index 99% rename from packages/reactivity/__tests__/ref.bench.ts rename to packages/reactivity/__benchmarks__/ref.bench.ts index 286d53e8840..0c05890179b 100644 --- a/packages/reactivity/__tests__/ref.bench.ts +++ b/packages/reactivity/__benchmarks__/ref.bench.ts @@ -26,7 +26,6 @@ describe('ref', () => { const v = ref(100) bench('write/read ref', () => { v.value = i++ - v.value }) } diff --git a/packages/reactivity/__tests__/computed.bench.ts b/packages/reactivity/__tests__/computed.bench.ts deleted file mode 100644 index 0ffa288ff1e..00000000000 --- a/packages/reactivity/__tests__/computed.bench.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { bench, describe } from 'vitest' -import { type ComputedRef, type Ref, computed, ref } from '../src/index' - -describe('computed', () => { - bench('create computed', () => { - computed(() => 100) - }) - - { - let i = 0 - const o = ref(100) - bench('write independent ref dep', () => { - o.value = i++ - }) - } - - { - const v = ref(100) - computed(() => v.value * 2) - let i = 0 - bench("write ref, don't read computed (never invoked)", () => { - v.value = i++ - }) - } - - { - const v = ref(100) - computed(() => { - return v.value * 2 - }) - let i = 0 - bench("write ref, don't read computed (never invoked)", () => { - v.value = i++ - }) - } - - { - const v = ref(100) - const c = computed(() => { - return v.value * 2 - }) - c.value - let i = 0 - bench("write ref, don't read computed (invoked)", () => { - v.value = i++ - }) - } - - { - const v = ref(100) - const c = computed(() => { - return v.value * 2 - }) - let i = 0 - bench('write ref, read computed', () => { - v.value = i++ - c.value - }) - } - - { - const v = ref(100) - const computeds = [] - for (let i = 0, n = 1000; i < n; i++) { - const c = computed(() => { - return v.value * 2 - }) - computeds.push(c) - } - let i = 0 - bench("write ref, don't read 1000 computeds (never invoked)", () => { - v.value = i++ - }) - } - - { - const v = ref(100) - const computeds = [] - for (let i = 0, n = 1000; i < n; i++) { - const c = computed(() => { - return v.value * 2 - }) - c.value - computeds.push(c) - } - let i = 0 - bench("write ref, don't read 1000 computeds (invoked)", () => { - v.value = i++ - }) - } - - { - const v = ref(100) - const computeds: ComputedRef[] = [] - for (let i = 0, n = 1000; i < n; i++) { - const c = computed(() => { - return v.value * 2 - }) - c.value - computeds.push(c) - } - let i = 0 - bench('write ref, read 1000 computeds', () => { - v.value = i++ - computeds.forEach(c => c.value) - }) - } - - { - const refs: Ref[] = [] - for (let i = 0, n = 1000; i < n; i++) { - refs.push(ref(i)) - } - const c = computed(() => { - let total = 0 - refs.forEach(ref => (total += ref.value)) - return total - }) - let i = 0 - const n = refs.length - bench('1000 refs, 1 computed', () => { - refs[i++ % n].value++ - c.value - }) - } -}) diff --git a/packages/reactivity/__tests__/computed.spec.ts b/packages/reactivity/__tests__/computed.spec.ts index c9f47720edd..e2325be54d2 100644 --- a/packages/reactivity/__tests__/computed.spec.ts +++ b/packages/reactivity/__tests__/computed.spec.ts @@ -1,4 +1,12 @@ -import { h, nextTick, nodeOps, render, serializeInner } from '@vue/runtime-test' +import { + h, + nextTick, + nodeOps, + onMounted, + onUnmounted, + render, + serializeInner, +} from '@vue/runtime-test' import { type DebuggerEvent, ITERATE_KEY, @@ -13,8 +21,8 @@ import { shallowRef, toRaw, } from '../src' -import { DirtyLevels } from '../src/constants' -import { COMPUTED_SIDE_EFFECT_WARN } from '../src/computed' +import { EffectFlags, pauseTracking, resetTracking } from '../src/effect' +import type { ComputedRef, ComputedRefImpl } from '../src/computed' describe('reactivity/computed', () => { it('should return updated value', () => { @@ -123,21 +131,6 @@ describe('reactivity/computed', () => { expect(getter2).toHaveBeenCalledTimes(2) }) - it('should no longer update when stopped', () => { - const value = reactive<{ foo?: number }>({}) - const cValue = computed(() => value.foo) - let dummy - effect(() => { - dummy = cValue.value - }) - expect(dummy).toBe(undefined) - value.foo = 1 - expect(dummy).toBe(1) - cValue.effect.stop() - value.foo = 2 - expect(dummy).toBe(1) - }) - it('should support setter', () => { const n = ref(1) const plusOne = computed({ @@ -219,12 +212,6 @@ describe('reactivity/computed', () => { expect(isReadonly(z.value.a)).toBe(false) }) - it('should expose value when stopped', () => { - const x = computed(() => 1) - x.effect.stop() - expect(x.value).toBe(1) - }) - it('debug: onTrack', () => { let events: DebuggerEvent[] = [] const onTrack = vi.fn((e: DebuggerEvent) => { @@ -238,19 +225,19 @@ describe('reactivity/computed', () => { expect(onTrack).toHaveBeenCalledTimes(3) expect(events).toEqual([ { - effect: c.effect, + effect: c, target: toRaw(obj), type: TrackOpTypes.GET, key: 'foo', }, { - effect: c.effect, + effect: c, target: toRaw(obj), type: TrackOpTypes.HAS, key: 'bar', }, { - effect: c.effect, + effect: c, target: toRaw(obj), type: TrackOpTypes.ITERATE, key: ITERATE_KEY, @@ -266,14 +253,14 @@ describe('reactivity/computed', () => { const obj = reactive<{ foo?: number }>({ foo: 1 }) const c = computed(() => obj.foo, { onTrigger }) - // computed won't trigger compute until accessed - c.value + // computed won't track until it has a subscriber + effect(() => c.value) obj.foo!++ expect(c.value).toBe(2) expect(onTrigger).toHaveBeenCalledTimes(1) expect(events[0]).toEqual({ - effect: c.effect, + effect: c, target: toRaw(obj), type: TriggerOpTypes.SET, key: 'foo', @@ -285,7 +272,7 @@ describe('reactivity/computed', () => { expect(c.value).toBeUndefined() expect(onTrigger).toHaveBeenCalledTimes(2) expect(events[1]).toEqual({ - effect: c.effect, + effect: c, target: toRaw(obj), type: TriggerOpTypes.DELETE, key: 'foo', @@ -380,17 +367,17 @@ describe('reactivity/computed', () => { const a = ref(0) const b = computed(() => { return a.value % 3 !== 0 - }) + }) as unknown as ComputedRefImpl const c = computed(() => { cSpy() if (a.value % 3 === 2) { return 'expensive' } return 'cheap' - }) + }) as unknown as ComputedRefImpl const d = computed(() => { return a.value % 3 === 2 - }) + }) as unknown as ComputedRefImpl const e = computed(() => { if (b.value) { if (d.value) { @@ -398,16 +385,15 @@ describe('reactivity/computed', () => { } } return c.value - }) + }) as unknown as ComputedRefImpl e.value a.value++ e.value - expect(e.effect.deps.length).toBe(3) - expect(e.effect.deps.indexOf((b as any).dep)).toBe(0) - expect(e.effect.deps.indexOf((d as any).dep)).toBe(1) - expect(e.effect.deps.indexOf((c as any).dep)).toBe(2) + expect(e.deps!.dep).toBe(b.dep) + expect(e.deps!.nextDep!.dep).toBe(d.dep) + expect(e.deps!.nextDep!.nextDep!.dep).toBe(c.dep) expect(cSpy).toHaveBeenCalledTimes(2) a.value++ @@ -456,17 +442,14 @@ describe('reactivity/computed', () => { expect(fnSpy).toBeCalledTimes(2) }) - it('should chained recurse effects clear dirty after trigger', () => { + it('should chained recursive effects clear dirty after trigger', () => { const v = ref(1) - const c1 = computed(() => v.value) - const c2 = computed(() => c1.value) + const c1 = computed(() => v.value) as unknown as ComputedRefImpl + const c2 = computed(() => c1.value) as unknown as ComputedRefImpl - c1.effect.allowRecurse = true - c2.effect.allowRecurse = true c2.value - - expect(c1.effect._dirtyLevel).toBe(DirtyLevels.NotDirty) - expect(c2.effect._dirtyLevel).toBe(DirtyLevels.NotDirty) + expect(c1.flags & EffectFlags.DIRTY).toBeFalsy() + expect(c2.flags & EffectFlags.DIRTY).toBeFalsy() }) it('should chained computeds dirtyLevel update with first computed effect', () => { @@ -481,15 +464,7 @@ describe('reactivity/computed', () => { const c3 = computed(() => c2.value) c3.value - - expect(c1.effect._dirtyLevel).toBe(DirtyLevels.Dirty) - expect(c2.effect._dirtyLevel).toBe( - DirtyLevels.MaybeDirty_ComputedSideEffect, - ) - expect(c3.effect._dirtyLevel).toBe( - DirtyLevels.MaybeDirty_ComputedSideEffect, - ) - expect(COMPUTED_SIDE_EFFECT_WARN).toHaveBeenWarned() + // expect(COMPUTED_SIDE_EFFECT_WARN).toHaveBeenWarned() }) it('should work when chained(ref+computed)', () => { @@ -502,9 +477,8 @@ describe('reactivity/computed', () => { }) const c2 = computed(() => v.value + c1.value) expect(c2.value).toBe('0foo') - expect(c2.effect._dirtyLevel).toBe(DirtyLevels.Dirty) expect(c2.value).toBe('1foo') - expect(COMPUTED_SIDE_EFFECT_WARN).toHaveBeenWarned() + // expect(COMPUTED_SIDE_EFFECT_WARN).toHaveBeenWarned() }) it('should trigger effect even computed already dirty', () => { @@ -519,15 +493,16 @@ describe('reactivity/computed', () => { const c2 = computed(() => v.value + c1.value) effect(() => { - fnSpy() - c2.value + fnSpy(c2.value) }) expect(fnSpy).toBeCalledTimes(1) - expect(c1.effect._dirtyLevel).toBe(DirtyLevels.Dirty) - expect(c2.effect._dirtyLevel).toBe(DirtyLevels.Dirty) + expect(fnSpy.mock.calls).toMatchObject([['0foo']]) + expect(v.value).toBe(1) v.value = 2 expect(fnSpy).toBeCalledTimes(2) - expect(COMPUTED_SIDE_EFFECT_WARN).toHaveBeenWarned() + expect(fnSpy.mock.calls).toMatchObject([['0foo'], ['2foo']]) + expect(v.value).toBe(2) + // expect(COMPUTED_SIDE_EFFECT_WARN).toHaveBeenWarned() }) // #10185 @@ -553,25 +528,12 @@ describe('reactivity/computed', () => { c3.value v2.value = true - expect(c2.effect._dirtyLevel).toBe(DirtyLevels.Dirty) - expect(c3.effect._dirtyLevel).toBe(DirtyLevels.MaybeDirty) c3.value - expect(c1.effect._dirtyLevel).toBe(DirtyLevels.Dirty) - expect(c2.effect._dirtyLevel).toBe( - DirtyLevels.MaybeDirty_ComputedSideEffect, - ) - expect(c3.effect._dirtyLevel).toBe( - DirtyLevels.MaybeDirty_ComputedSideEffect, - ) - v1.value.v.value = 999 - expect(c1.effect._dirtyLevel).toBe(DirtyLevels.Dirty) - expect(c2.effect._dirtyLevel).toBe(DirtyLevels.MaybeDirty) - expect(c3.effect._dirtyLevel).toBe(DirtyLevels.MaybeDirty) expect(c3.value).toBe('yes') - expect(COMPUTED_SIDE_EFFECT_WARN).toHaveBeenWarned() + // expect(COMPUTED_SIDE_EFFECT_WARN).toHaveBeenWarned() }) it('should be not dirty after deps mutate (mutate deps in computed)', async () => { @@ -593,10 +555,10 @@ describe('reactivity/computed', () => { await nextTick() await nextTick() expect(serializeInner(root)).toBe(`2`) - expect(COMPUTED_SIDE_EFFECT_WARN).toHaveBeenWarned() + // expect(COMPUTED_SIDE_EFFECT_WARN).toHaveBeenWarned() }) - it('should not trigger effect scheduler by recurse computed effect', async () => { + it('should not trigger effect scheduler by recursive computed effect', async () => { const v = ref('Hello') const c = computed(() => { v.value += ' World' @@ -615,7 +577,279 @@ describe('reactivity/computed', () => { v.value += ' World' await nextTick() - expect(serializeInner(root)).toBe('Hello World World World World') - expect(COMPUTED_SIDE_EFFECT_WARN).toHaveBeenWarned() + expect(serializeInner(root)).toBe('Hello World World World') + // expect(COMPUTED_SIDE_EFFECT_WARN).toHaveBeenWarned() + }) + + test('should not trigger if value did not change', () => { + const src = ref(0) + const c = computed(() => src.value % 2) + const spy = vi.fn() + effect(() => { + spy(c.value) + }) + expect(spy).toHaveBeenCalledTimes(1) + src.value = 2 + + // should not trigger + expect(spy).toHaveBeenCalledTimes(1) + + src.value = 3 + src.value = 5 + // should trigger because latest value changes + expect(spy).toHaveBeenCalledTimes(2) + }) + + test('chained computed trigger', () => { + const effectSpy = vi.fn() + const c1Spy = vi.fn() + const c2Spy = vi.fn() + + const src = ref(0) + const c1 = computed(() => { + c1Spy() + return src.value % 2 + }) + const c2 = computed(() => { + c2Spy() + return c1.value + 1 + }) + + effect(() => { + effectSpy(c2.value) + }) + + expect(c1Spy).toHaveBeenCalledTimes(1) + expect(c2Spy).toHaveBeenCalledTimes(1) + expect(effectSpy).toHaveBeenCalledTimes(1) + + src.value = 1 + expect(c1Spy).toHaveBeenCalledTimes(2) + expect(c2Spy).toHaveBeenCalledTimes(2) + expect(effectSpy).toHaveBeenCalledTimes(2) + }) + + test('chained computed avoid re-compute', () => { + const effectSpy = vi.fn() + const c1Spy = vi.fn() + const c2Spy = vi.fn() + + const src = ref(0) + const c1 = computed(() => { + c1Spy() + return src.value % 2 + }) + const c2 = computed(() => { + c2Spy() + return c1.value + 1 + }) + + effect(() => { + effectSpy(c2.value) + }) + + expect(effectSpy).toHaveBeenCalledTimes(1) + src.value = 2 + src.value = 4 + src.value = 6 + expect(c1Spy).toHaveBeenCalledTimes(4) + // c2 should not have to re-compute because c1 did not change. + expect(c2Spy).toHaveBeenCalledTimes(1) + // effect should not trigger because c2 did not change. + expect(effectSpy).toHaveBeenCalledTimes(1) + }) + + test('chained computed value invalidation', () => { + const effectSpy = vi.fn() + const c1Spy = vi.fn() + const c2Spy = vi.fn() + + const src = ref(0) + const c1 = computed(() => { + c1Spy() + return src.value % 2 + }) + const c2 = computed(() => { + c2Spy() + return c1.value + 1 + }) + + effect(() => { + effectSpy(c2.value) + }) + + expect(effectSpy).toHaveBeenCalledTimes(1) + expect(effectSpy).toHaveBeenCalledWith(1) + expect(c2.value).toBe(1) + + expect(c1Spy).toHaveBeenCalledTimes(1) + expect(c2Spy).toHaveBeenCalledTimes(1) + + src.value = 1 + // value should be available sync + expect(c2.value).toBe(2) + expect(c2Spy).toHaveBeenCalledTimes(2) + }) + + test('sync access of invalidated chained computed should not prevent final effect from running', () => { + const effectSpy = vi.fn() + const c1Spy = vi.fn() + const c2Spy = vi.fn() + + const src = ref(0) + const c1 = computed(() => { + c1Spy() + return src.value % 2 + }) + const c2 = computed(() => { + c2Spy() + return c1.value + 1 + }) + + effect(() => { + effectSpy(c2.value) + }) + expect(effectSpy).toHaveBeenCalledTimes(1) + + src.value = 1 + // sync access c2 + c2.value + expect(effectSpy).toHaveBeenCalledTimes(2) + }) + + it('computed should force track in untracked zone', () => { + const n = ref(0) + const spy1 = vi.fn() + const spy2 = vi.fn() + + let c: ComputedRef + effect(() => { + spy1() + pauseTracking() + n.value + c = computed(() => n.value + 1) + // access computed now to force refresh + c.value + effect(() => spy2(c.value)) + n.value + resetTracking() + }) + + expect(spy1).toHaveBeenCalledTimes(1) + expect(spy2).toHaveBeenCalledTimes(1) + + n.value++ + // outer effect should not trigger + expect(spy1).toHaveBeenCalledTimes(1) + // inner effect should trigger + expect(spy2).toHaveBeenCalledTimes(2) + }) + + // not recommended behavior, but needed for backwards compatibility + // used in VueUse asyncComputed + it('computed side effect should be able trigger', () => { + const a = ref(false) + const b = ref(false) + const c = computed(() => { + a.value = true + return b.value + }) + effect(() => { + if (a.value) { + b.value = true + } + }) + expect(b.value).toBe(false) + // accessing c triggers change + c.value + expect(b.value).toBe(true) + expect(c.value).toBe(true) + }) + + it('chained computed should work when accessed before having subs', () => { + const n = ref(0) + const c = computed(() => n.value) + const d = computed(() => c.value + 1) + const spy = vi.fn() + + // access + d.value + + let dummy + effect(() => { + spy() + dummy = d.value + }) + expect(spy).toHaveBeenCalledTimes(1) + expect(dummy).toBe(1) + + n.value++ + expect(spy).toHaveBeenCalledTimes(2) + expect(dummy).toBe(2) + }) + + // #10236 + it('chained computed should still refresh after owner component unmount', async () => { + const a = ref(0) + const spy = vi.fn() + + const Child = { + setup() { + const b = computed(() => a.value + 1) + const c = computed(() => b.value + 1) + // access + c.value + onUnmounted(() => spy(c.value)) + return () => {} + }, + } + + const show = ref(true) + const Parent = { + setup() { + return () => (show.value ? h(Child) : null) + }, + } + + render(h(Parent), nodeOps.createElement('div')) + + a.value++ + show.value = false + + await nextTick() + expect(spy).toHaveBeenCalledWith(3) + }) + + // case: radix-vue `useForwardExpose` sets a template ref during mount, + // and checks for the element's closest form element in a computed. + // the computed is expected to only evaluate after mount. + it('computed deps should only be refreshed when the subscribing effect is run, not when scheduled', async () => { + const calls: string[] = [] + const a = ref(0) + const b = computed(() => { + calls.push('b eval') + return a.value + 1 + }) + + const App = { + setup() { + onMounted(() => { + calls.push('mounted') + }) + return () => + h( + 'div', + { + ref: () => (a.value = 1), + }, + b.value, + ) + }, + } + + render(h(App), nodeOps.createElement('div')) + + await nextTick() + expect(calls).toMatchObject(['b eval', 'mounted', 'b eval']) }) }) diff --git a/packages/reactivity/__tests__/deferredComputed.spec.ts b/packages/reactivity/__tests__/deferredComputed.spec.ts deleted file mode 100644 index 8e78ba959c3..00000000000 --- a/packages/reactivity/__tests__/deferredComputed.spec.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { computed, effect, ref } from '../src' - -describe('deferred computed', () => { - test('should not trigger if value did not change', () => { - const src = ref(0) - const c = computed(() => src.value % 2) - const spy = vi.fn() - effect(() => { - spy(c.value) - }) - expect(spy).toHaveBeenCalledTimes(1) - src.value = 2 - - // should not trigger - expect(spy).toHaveBeenCalledTimes(1) - - src.value = 3 - src.value = 5 - // should trigger because latest value changes - expect(spy).toHaveBeenCalledTimes(2) - }) - - test('chained computed trigger', () => { - const effectSpy = vi.fn() - const c1Spy = vi.fn() - const c2Spy = vi.fn() - - const src = ref(0) - const c1 = computed(() => { - c1Spy() - return src.value % 2 - }) - const c2 = computed(() => { - c2Spy() - return c1.value + 1 - }) - - effect(() => { - effectSpy(c2.value) - }) - - expect(c1Spy).toHaveBeenCalledTimes(1) - expect(c2Spy).toHaveBeenCalledTimes(1) - expect(effectSpy).toHaveBeenCalledTimes(1) - - src.value = 1 - expect(c1Spy).toHaveBeenCalledTimes(2) - expect(c2Spy).toHaveBeenCalledTimes(2) - expect(effectSpy).toHaveBeenCalledTimes(2) - }) - - test('chained computed avoid re-compute', () => { - const effectSpy = vi.fn() - const c1Spy = vi.fn() - const c2Spy = vi.fn() - - const src = ref(0) - const c1 = computed(() => { - c1Spy() - return src.value % 2 - }) - const c2 = computed(() => { - c2Spy() - return c1.value + 1 - }) - - effect(() => { - effectSpy(c2.value) - }) - - expect(effectSpy).toHaveBeenCalledTimes(1) - src.value = 2 - src.value = 4 - src.value = 6 - expect(c1Spy).toHaveBeenCalledTimes(4) - // c2 should not have to re-compute because c1 did not change. - expect(c2Spy).toHaveBeenCalledTimes(1) - // effect should not trigger because c2 did not change. - expect(effectSpy).toHaveBeenCalledTimes(1) - }) - - test('chained computed value invalidation', () => { - const effectSpy = vi.fn() - const c1Spy = vi.fn() - const c2Spy = vi.fn() - - const src = ref(0) - const c1 = computed(() => { - c1Spy() - return src.value % 2 - }) - const c2 = computed(() => { - c2Spy() - return c1.value + 1 - }) - - effect(() => { - effectSpy(c2.value) - }) - - expect(effectSpy).toHaveBeenCalledTimes(1) - expect(effectSpy).toHaveBeenCalledWith(1) - expect(c2.value).toBe(1) - - expect(c1Spy).toHaveBeenCalledTimes(1) - expect(c2Spy).toHaveBeenCalledTimes(1) - - src.value = 1 - // value should be available sync - expect(c2.value).toBe(2) - expect(c2Spy).toHaveBeenCalledTimes(2) - }) - - test('sync access of invalidated chained computed should not prevent final effect from running', () => { - const effectSpy = vi.fn() - const c1Spy = vi.fn() - const c2Spy = vi.fn() - - const src = ref(0) - const c1 = computed(() => { - c1Spy() - return src.value % 2 - }) - const c2 = computed(() => { - c2Spy() - return c1.value + 1 - }) - - effect(() => { - effectSpy(c2.value) - }) - expect(effectSpy).toHaveBeenCalledTimes(1) - - src.value = 1 - // sync access c2 - c2.value - expect(effectSpy).toHaveBeenCalledTimes(2) - }) - - test('should not compute if deactivated before scheduler is called', () => { - const c1Spy = vi.fn() - const src = ref(0) - const c1 = computed(() => { - c1Spy() - return src.value % 2 - }) - effect(() => c1.value) - expect(c1Spy).toHaveBeenCalledTimes(1) - - c1.effect.stop() - // trigger - src.value++ - expect(c1Spy).toHaveBeenCalledTimes(1) - }) -}) diff --git a/packages/reactivity/__tests__/effect.spec.ts b/packages/reactivity/__tests__/effect.spec.ts index bd26934f1ce..99453d35d87 100644 --- a/packages/reactivity/__tests__/effect.spec.ts +++ b/packages/reactivity/__tests__/effect.spec.ts @@ -11,8 +11,7 @@ import { stop, toRaw, } from '../src/index' -import { pauseScheduling, resetScheduling } from '../src/effect' -import { ITERATE_KEY, getDepFromReactive } from '../src/reactiveEffect' +import { type Dep, ITERATE_KEY, getDepFromReactive } from '../src/dep' import { computed, h, @@ -22,6 +21,12 @@ import { render, serializeInner, } from '@vue/runtime-test' +import { + endBatch, + pauseTracking, + resetTracking, + startBatch, +} from '../src/effect' describe('reactivity/effect', () => { it('should run the passed function once (wrapped by a effect)', () => { @@ -698,18 +703,6 @@ describe('reactivity/effect', () => { expect(dummy).toBe(1) }) - it('lazy', () => { - const obj = reactive({ foo: 1 }) - let dummy - const runner = effect(() => (dummy = obj.foo), { lazy: true }) - expect(dummy).toBe(undefined) - - expect(runner()).toBe(1) - expect(dummy).toBe(1) - obj.foo = 2 - expect(dummy).toBe(2) - }) - it('scheduler', () => { let dummy let run: any @@ -1005,7 +998,7 @@ describe('reactivity/effect', () => { }) }) - it('should be triggered once with pauseScheduling', () => { + it('should be triggered once with batching', () => { const counter = reactive({ num: 0 }) const counterSpy = vi.fn(() => counter.num) @@ -1013,10 +1006,10 @@ describe('reactivity/effect', () => { counterSpy.mockClear() - pauseScheduling() + startBatch() counter.num++ counter.num++ - resetScheduling() + endBatch() expect(counterSpy).toHaveBeenCalledTimes(1) }) @@ -1049,47 +1042,76 @@ describe('reactivity/effect', () => { expect(renderSpy).toHaveBeenCalledTimes(2) }) - describe('empty dep cleanup', () => { + it('nested effect should force track in untracked zone', () => { + const n = ref(0) + const spy1 = vi.fn() + const spy2 = vi.fn() + + effect(() => { + spy1() + pauseTracking() + n.value + effect(() => { + n.value + spy2() + }) + n.value + resetTracking() + }) + + expect(spy1).toHaveBeenCalledTimes(1) + expect(spy2).toHaveBeenCalledTimes(1) + + n.value++ + // outer effect should not trigger + expect(spy1).toHaveBeenCalledTimes(1) + // inner effect should trigger + expect(spy2).toHaveBeenCalledTimes(2) + }) + + describe('dep unsubscribe', () => { + function getSubCount(dep: Dep | undefined) { + let count = 0 + let sub = dep!.subs + while (sub) { + count++ + sub = sub.prevSub + } + return count + } + it('should remove the dep when the effect is stopped', () => { const obj = reactive({ prop: 1 }) - expect(getDepFromReactive(toRaw(obj), 'prop')).toBeUndefined() const runner = effect(() => obj.prop) const dep = getDepFromReactive(toRaw(obj), 'prop') - expect(dep).toHaveLength(1) + expect(getSubCount(dep)).toBe(1) obj.prop = 2 - expect(getDepFromReactive(toRaw(obj), 'prop')).toBe(dep) - expect(dep).toHaveLength(1) + expect(getSubCount(dep)).toBe(1) stop(runner) - expect(getDepFromReactive(toRaw(obj), 'prop')).toBeUndefined() + expect(getSubCount(dep)).toBe(0) obj.prop = 3 runner() - expect(getDepFromReactive(toRaw(obj), 'prop')).toBeUndefined() + expect(getSubCount(dep)).toBe(0) }) it('should only remove the dep when the last effect is stopped', () => { const obj = reactive({ prop: 1 }) - expect(getDepFromReactive(toRaw(obj), 'prop')).toBeUndefined() const runner1 = effect(() => obj.prop) const dep = getDepFromReactive(toRaw(obj), 'prop') - expect(dep).toHaveLength(1) + expect(getSubCount(dep)).toBe(1) const runner2 = effect(() => obj.prop) - expect(getDepFromReactive(toRaw(obj), 'prop')).toBe(dep) - expect(dep).toHaveLength(2) + expect(getSubCount(dep)).toBe(2) obj.prop = 2 - expect(getDepFromReactive(toRaw(obj), 'prop')).toBe(dep) - expect(dep).toHaveLength(2) + expect(getSubCount(dep)).toBe(2) stop(runner1) - expect(getDepFromReactive(toRaw(obj), 'prop')).toBe(dep) - expect(dep).toHaveLength(1) + expect(getSubCount(dep)).toBe(1) obj.prop = 3 - expect(getDepFromReactive(toRaw(obj), 'prop')).toBe(dep) - expect(dep).toHaveLength(1) + expect(getSubCount(dep)).toBe(1) stop(runner2) - expect(getDepFromReactive(toRaw(obj), 'prop')).toBeUndefined() obj.prop = 4 runner1() runner2() - expect(getDepFromReactive(toRaw(obj), 'prop')).toBeUndefined() + expect(getSubCount(dep)).toBe(0) }) it('should remove the dep when it is no longer used by the effect', () => { @@ -1098,18 +1120,15 @@ describe('reactivity/effect', () => { b: 2, c: 'a', }) - expect(getDepFromReactive(toRaw(obj), 'prop')).toBeUndefined() effect(() => obj[obj.c]) const depC = getDepFromReactive(toRaw(obj), 'c') - expect(getDepFromReactive(toRaw(obj), 'a')).toHaveLength(1) - expect(getDepFromReactive(toRaw(obj), 'b')).toBeUndefined() - expect(depC).toHaveLength(1) + expect(getSubCount(getDepFromReactive(toRaw(obj), 'a'))).toBe(1) + expect(getSubCount(depC)).toBe(1) obj.c = 'b' obj.a = 4 - expect(getDepFromReactive(toRaw(obj), 'a')).toBeUndefined() - expect(getDepFromReactive(toRaw(obj), 'b')).toHaveLength(1) + expect(getSubCount(getDepFromReactive(toRaw(obj), 'b'))).toBe(1) expect(getDepFromReactive(toRaw(obj), 'c')).toBe(depC) - expect(depC).toHaveLength(1) + expect(getSubCount(depC)).toBe(1) }) }) }) diff --git a/packages/reactivity/__tests__/effectScope.spec.ts b/packages/reactivity/__tests__/effectScope.spec.ts index f7e3241ccd6..8a7f26dbb2d 100644 --- a/packages/reactivity/__tests__/effectScope.spec.ts +++ b/packages/reactivity/__tests__/effectScope.spec.ts @@ -247,16 +247,15 @@ describe('reactivity/effect/scope', () => { watchEffect(() => { watchEffectSpy() r.value + c.value }) }) - c!.value // computed is lazy so trigger collection expect(computedSpy).toHaveBeenCalledTimes(1) expect(watchSpy).toHaveBeenCalledTimes(0) expect(watchEffectSpy).toHaveBeenCalledTimes(1) r.value++ - c!.value await nextTick() expect(computedSpy).toHaveBeenCalledTimes(2) expect(watchSpy).toHaveBeenCalledTimes(1) @@ -265,7 +264,6 @@ describe('reactivity/effect/scope', () => { scope.stop() r.value++ - c!.value await nextTick() // should not trigger anymore expect(computedSpy).toHaveBeenCalledTimes(2) diff --git a/packages/reactivity/__tests__/gc.spec.ts b/packages/reactivity/__tests__/gc.spec.ts index 953765dd1d9..678600751a9 100644 --- a/packages/reactivity/__tests__/gc.spec.ts +++ b/packages/reactivity/__tests__/gc.spec.ts @@ -6,7 +6,7 @@ import { shallowRef as ref, toRaw, } from '../src/index' -import { getDepFromReactive } from '../src/reactiveEffect' +import { getDepFromReactive } from '../src/dep' describe.skipIf(!global.gc)('reactivity/gc', () => { const gc = () => { diff --git a/packages/reactivity/__tests__/reactiveObject.bench.ts b/packages/reactivity/__tests__/reactiveObject.bench.ts deleted file mode 100644 index 71632283f69..00000000000 --- a/packages/reactivity/__tests__/reactiveObject.bench.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { bench } from 'vitest' -import { type ComputedRef, computed, reactive } from '../src' - -bench('create reactive obj', () => { - reactive({ a: 1 }) -}) - -{ - let i = 0 - const r = reactive({ a: 1 }) - bench('write reactive obj property', () => { - r.a = i++ - }) -} - -{ - const r = reactive({ a: 1 }) - computed(() => { - return r.a * 2 - }) - let i = 0 - bench("write reactive obj, don't read computed (never invoked)", () => { - r.a = i++ - }) -} - -{ - const r = reactive({ a: 1 }) - const c = computed(() => { - return r.a * 2 - }) - c.value - let i = 0 - bench("write reactive obj, don't read computed (invoked)", () => { - r.a = i++ - }) -} - -{ - const r = reactive({ a: 1 }) - const c = computed(() => { - return r.a * 2 - }) - let i = 0 - bench('write reactive obj, read computed', () => { - r.a = i++ - c.value - }) -} - -{ - const r = reactive({ a: 1 }) - const computeds = [] - for (let i = 0, n = 1000; i < n; i++) { - const c = computed(() => { - return r.a * 2 - }) - computeds.push(c) - } - let i = 0 - bench("write reactive obj, don't read 1000 computeds (never invoked)", () => { - r.a = i++ - }) -} - -{ - const r = reactive({ a: 1 }) - const computeds = [] - for (let i = 0, n = 1000; i < n; i++) { - const c = computed(() => { - return r.a * 2 - }) - c.value - computeds.push(c) - } - let i = 0 - bench("write reactive obj, don't read 1000 computeds (invoked)", () => { - r.a = i++ - }) -} - -{ - const r = reactive({ a: 1 }) - const computeds: ComputedRef[] = [] - for (let i = 0, n = 1000; i < n; i++) { - const c = computed(() => { - return r.a * 2 - }) - computeds.push(c) - } - let i = 0 - bench('write reactive obj, read 1000 computeds', () => { - r.a = i++ - computeds.forEach(c => c.value) - }) -} - -{ - const reactives: Record[] = [] - for (let i = 0, n = 1000; i < n; i++) { - reactives.push(reactive({ a: i })) - } - const c = computed(() => { - let total = 0 - reactives.forEach(r => (total += r.a)) - return total - }) - let i = 0 - const n = reactives.length - bench('1000 reactive objs, 1 computed', () => { - reactives[i++ % n].a++ - c.value - }) -} diff --git a/packages/reactivity/__tests__/readonly.spec.ts b/packages/reactivity/__tests__/readonly.spec.ts index 66da71a8c9e..e86c7fa5b50 100644 --- a/packages/reactivity/__tests__/readonly.spec.ts +++ b/packages/reactivity/__tests__/readonly.spec.ts @@ -409,7 +409,7 @@ describe('reactivity/readonly', () => { const eff = effect(() => { roArr.includes(2) }) - expect(eff.effect.deps.length).toBe(0) + expect(eff.effect.deps).toBeUndefined() }) test('readonly should track and trigger if wrapping reactive original (collection)', () => { diff --git a/packages/reactivity/__tests__/ref.spec.ts b/packages/reactivity/__tests__/ref.spec.ts index 2b2024d9723..ed917dbdd92 100644 --- a/packages/reactivity/__tests__/ref.spec.ts +++ b/packages/reactivity/__tests__/ref.spec.ts @@ -442,4 +442,15 @@ describe('reactivity/ref', () => { expect(a.value).toBe(rr) expect(a.value).not.toBe(r) }) + + test('should not trigger when setting the same raw object', () => { + const obj = {} + const r = ref(obj) + const spy = vi.fn() + effect(() => spy(r.value)) + expect(spy).toHaveBeenCalledTimes(1) + + r.value = obj + expect(spy).toHaveBeenCalledTimes(1) + }) }) diff --git a/packages/reactivity/__tests__/shallowReactive.spec.ts b/packages/reactivity/__tests__/shallowReactive.spec.ts index e9b64d39b36..a5218658a27 100644 --- a/packages/reactivity/__tests__/shallowReactive.spec.ts +++ b/packages/reactivity/__tests__/shallowReactive.spec.ts @@ -160,6 +160,7 @@ describe('shallowReactive', () => { shallowArray.pop() expect(size).toBe(0) }) + test('should not observe when iterating', () => { const shallowArray = shallowReactive([]) const a = {} diff --git a/packages/reactivity/src/baseHandlers.ts b/packages/reactivity/src/baseHandlers.ts index a1b3003a5e7..ab2ed378129 100644 --- a/packages/reactivity/src/baseHandlers.ts +++ b/packages/reactivity/src/baseHandlers.ts @@ -11,13 +11,7 @@ import { toRaw, } from './reactive' import { ReactiveFlags, TrackOpTypes, TriggerOpTypes } from './constants' -import { - pauseScheduling, - pauseTracking, - resetScheduling, - resetTracking, -} from './effect' -import { ITERATE_KEY, track, trigger } from './reactiveEffect' +import { ITERATE_KEY, track, trigger } from './dep' import { hasChanged, hasOwn, @@ -29,6 +23,7 @@ import { } from '@vue/shared' import { isRef } from './ref' import { warn } from './warning' +import { endBatch, pauseTracking, resetTracking, startBatch } from './effect' const isNonTrackableKeys = /*#__PURE__*/ makeMap(`__proto__,__v_isRef,__isVue`) @@ -69,11 +64,11 @@ function createArrayInstrumentations() { // which leads to infinite loops in some cases (#2137) ;(['push', 'pop', 'shift', 'unshift', 'splice'] as const).forEach(key => { instrumentations[key] = function (this: unknown[], ...args: unknown[]) { + startBatch() pauseTracking() - pauseScheduling() const res = (toRaw(this) as any)[key].apply(this, args) - resetScheduling() resetTracking() + endBatch() return res } }) @@ -133,7 +128,14 @@ class BaseReactiveHandler implements ProxyHandler { } } - const res = Reflect.get(target, key, receiver) + const res = Reflect.get( + target, + key, + // if this is a proxy wrapping a ref, return methods using the raw ref + // as receiver so that we don't have to call `toRaw` on the ref in all + // its class methods + isRef(target) ? target : receiver, + ) if (isSymbol(key) ? builtInSymbols.has(key) : isNonTrackableKeys(key)) { return res diff --git a/packages/reactivity/src/collectionHandlers.ts b/packages/reactivity/src/collectionHandlers.ts index 58e69b1cc62..2636287b610 100644 --- a/packages/reactivity/src/collectionHandlers.ts +++ b/packages/reactivity/src/collectionHandlers.ts @@ -1,10 +1,5 @@ import { toRaw, toReactive, toReadonly } from './reactive' -import { - ITERATE_KEY, - MAP_KEY_ITERATE_KEY, - track, - trigger, -} from './reactiveEffect' +import { ITERATE_KEY, MAP_KEY_ITERATE_KEY, track, trigger } from './dep' import { ReactiveFlags, TrackOpTypes, TriggerOpTypes } from './constants' import { capitalize, hasChanged, hasOwn, isMap, toRawType } from '@vue/shared' diff --git a/packages/reactivity/src/computed.ts b/packages/reactivity/src/computed.ts index a4b74172fcf..3e0fce6ec02 100644 --- a/packages/reactivity/src/computed.ts +++ b/packages/reactivity/src/computed.ts @@ -1,10 +1,18 @@ -import { type DebuggerOptions, ReactiveEffect } from './effect' -import { type Ref, trackRefValue, triggerRefValue } from './ref' -import { NOOP, hasChanged, isFunction } from '@vue/shared' -import { toRaw } from './reactive' -import type { Dep } from './dep' -import { DirtyLevels, ReactiveFlags } from './constants' +import { isFunction } from '@vue/shared' +import { + type DebuggerEvent, + type DebuggerOptions, + EffectFlags, + type Link, + type ReactiveEffect, + type Subscriber, + activeSub, + refreshComputed, +} from './effect' +import type { Ref } from './ref' import { warn } from './warning' +import { Dep, globalVersion } from './dep' +import { ReactiveFlags, TrackOpTypes } from './constants' declare const ComputedRefSymbol: unique symbol @@ -14,7 +22,10 @@ export interface ComputedRef extends WritableComputedRef { } export interface WritableComputedRef extends Ref { - readonly effect: ReactiveEffect + /** + * @deprecated computed no longer uses effect + */ + effect: ReactiveEffect } export type ComputedGetter = (oldValue?: T) => T @@ -25,74 +36,71 @@ export interface WritableComputedOptions { set: ComputedSetter } -export const COMPUTED_SIDE_EFFECT_WARN = - `Computed is still dirty after getter evaluation,` + - ` likely because a computed is mutating its own dependency in its getter.` + - ` State mutations in computed getters should be avoided. ` + - ` Check the docs for more details: https://vuejs.org/guide/essentials/computed.html#getters-should-be-side-effect-free` - -export class ComputedRefImpl { - public dep?: Dep = undefined - - private _value!: T - public readonly effect: ReactiveEffect - - public readonly __v_isRef = true - public readonly [ReactiveFlags.IS_READONLY]: boolean = false - - public _cacheable: boolean +/** + * @internal + */ +export class ComputedRefImpl implements Subscriber { + // A computed is a ref + _value: any = undefined + readonly dep = new Dep(this) + readonly __v_isRef = true; + readonly [ReactiveFlags.IS_READONLY]: boolean + // A computed is also a subscriber that tracks other deps + deps?: Link = undefined + depsTail?: Link = undefined + // track variaous states + flags = EffectFlags.DIRTY + // last seen global version + globalVersion = globalVersion - 1 + // for backwards compat + effect = this + + // dev only + onTrack?: (event: DebuggerEvent) => void + // dev only + onTrigger?: (event: DebuggerEvent) => void constructor( - getter: ComputedGetter, - private readonly _setter: ComputedSetter, - isReadonly: boolean, - isSSR: boolean, + public fn: ComputedGetter, + private readonly setter: ComputedSetter | undefined, + public isSSR: boolean, ) { - this.effect = new ReactiveEffect( - () => getter(this._value), - () => - triggerRefValue( - this, - this.effect._dirtyLevel === DirtyLevels.MaybeDirty_ComputedSideEffect - ? DirtyLevels.MaybeDirty_ComputedSideEffect - : DirtyLevels.MaybeDirty, - ), - ) - this.effect.computed = this - this.effect.active = this._cacheable = !isSSR - this[ReactiveFlags.IS_READONLY] = isReadonly + this.__v_isReadonly = !setter } - get value() { - // the computed ref may get wrapped by other proxies e.g. readonly() #3376 - const self = toRaw(this) - if ( - (!self._cacheable || self.effect.dirty) && - hasChanged(self._value, (self._value = self.effect.run()!)) - ) { - triggerRefValue(self, DirtyLevels.Dirty) + notify() { + // avoid infinite self recursion + if (activeSub !== this) { + this.flags |= EffectFlags.DIRTY + this.dep.notify() + } else if (__DEV__) { + // TODO warn } - trackRefValue(self) - if (self.effect._dirtyLevel >= DirtyLevels.MaybeDirty_ComputedSideEffect) { - __DEV__ && warn(COMPUTED_SIDE_EFFECT_WARN) - triggerRefValue(self, DirtyLevels.MaybeDirty_ComputedSideEffect) - } - return self._value - } - - set value(newValue: T) { - this._setter(newValue) } - // #region polyfill _dirty for backward compatibility third party code for Vue <= 3.3.x - get _dirty() { - return this.effect.dirty + get value() { + const link = __DEV__ + ? this.dep.track({ + target: this, + type: TrackOpTypes.GET, + key: 'value', + }) + : this.dep.track() + refreshComputed(this) + // sync version after evaluation + if (link) { + link.version = this.dep.version + } + return this._value } - set _dirty(v) { - this.effect.dirty = v + set value(newValue) { + if (this.setter) { + this.setter(newValue) + } else if (__DEV__) { + warn('Write operation failed: computed value is readonly') + } } - // #endregion } /** @@ -142,26 +150,20 @@ export function computed( isSSR = false, ) { let getter: ComputedGetter - let setter: ComputedSetter + let setter: ComputedSetter | undefined - const onlyGetter = isFunction(getterOrOptions) - if (onlyGetter) { + if (isFunction(getterOrOptions)) { getter = getterOrOptions - setter = __DEV__ - ? () => { - warn('Write operation failed: computed value is readonly') - } - : NOOP } else { getter = getterOrOptions.get setter = getterOrOptions.set } - const cRef = new ComputedRefImpl(getter, setter, onlyGetter || !setter, isSSR) + const cRef = new ComputedRefImpl(getter, setter, isSSR) if (__DEV__ && debugOptions && !isSSR) { - cRef.effect.onTrack = debugOptions.onTrack - cRef.effect.onTrigger = debugOptions.onTrigger + cRef.onTrack = debugOptions.onTrack + cRef.onTrigger = debugOptions.onTrigger } return cRef as any diff --git a/packages/reactivity/src/deferredComputed.ts b/packages/reactivity/src/deferredComputed.ts deleted file mode 100644 index 1dbba1f3f03..00000000000 --- a/packages/reactivity/src/deferredComputed.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { computed } from './computed' - -/** - * @deprecated use `computed` instead. See #5912 - */ -export const deferredComputed = computed diff --git a/packages/reactivity/src/dep.ts b/packages/reactivity/src/dep.ts index c8e8a130dc9..5ba61d3a03f 100644 --- a/packages/reactivity/src/dep.ts +++ b/packages/reactivity/src/dep.ts @@ -1,17 +1,295 @@ -import type { ReactiveEffect } from './effect' +import { extend, isArray, isIntegerKey, isMap, isSymbol } from '@vue/shared' import type { ComputedRefImpl } from './computed' +import { type TrackOpTypes, TriggerOpTypes } from './constants' +import { + type DebuggerEventExtraInfo, + EffectFlags, + type Link, + activeSub, + endBatch, + shouldTrack, + startBatch, +} from './effect' -export type Dep = Map & { - cleanup: () => void - computed?: ComputedRefImpl +/** + * Incremented every time a reactive change happens + * This is used to give computed a fast path to avoid re-compute when nothing + * has changed. + */ +export let globalVersion = 0 + +/** + * @internal + */ +export class Dep { + version = 0 + /** + * Link between this dep and the current active effect + */ + activeLink?: Link = undefined + /** + * Doubly linked list representing the subscribing effects (tail) + */ + subs?: Link = undefined + + constructor(public computed?: ComputedRefImpl) {} + + track(debugInfo?: DebuggerEventExtraInfo): Link | undefined { + if (!activeSub || !shouldTrack) { + return + } + + let link = this.activeLink + if (link === undefined || link.sub !== activeSub) { + link = this.activeLink = { + dep: this, + sub: activeSub, + version: this.version, + nextDep: undefined, + prevDep: undefined, + nextSub: undefined, + prevSub: undefined, + prevActiveLink: undefined, + } + + // add the link to the activeEffect as a dep (as tail) + if (!activeSub.deps) { + activeSub.deps = activeSub.depsTail = link + } else { + link.prevDep = activeSub.depsTail + activeSub.depsTail!.nextDep = link + activeSub.depsTail = link + } + + if (activeSub.flags & EffectFlags.TRACKING) { + addSub(link) + } + } else if (link.version === -1) { + // reused from last run - already a sub, just sync version + link.version = this.version + + // If this dep has a next, it means it's not at the tail - move it to the + // tail. This ensures the effect's dep list is in the order they are + // accessed during evaluation. + if (link.nextDep) { + const next = link.nextDep + next.prevDep = link.prevDep + if (link.prevDep) { + link.prevDep.nextDep = next + } + + link.prevDep = activeSub.depsTail + link.nextDep = undefined + activeSub.depsTail!.nextDep = link + activeSub.depsTail = link + + // this was the head - point to the new head + if (activeSub.deps === link) { + activeSub.deps = next + } + } + } + + if (__DEV__ && activeSub.onTrack) { + activeSub.onTrack( + extend( + { + effect: activeSub, + }, + debugInfo, + ), + ) + } + + return link + } + + trigger(debugInfo?: DebuggerEventExtraInfo) { + this.version++ + globalVersion++ + this.notify(debugInfo) + } + + notify(debugInfo?: DebuggerEventExtraInfo) { + startBatch() + try { + for (let link = this.subs; link; link = link.prevSub) { + if ( + __DEV__ && + link.sub.onTrigger && + !(link.sub.flags & EffectFlags.NOTIFIED) + ) { + link.sub.onTrigger( + extend( + { + effect: link.sub, + }, + debugInfo, + ), + ) + } + link.sub.notify() + } + } finally { + endBatch() + } + } +} + +function addSub(link: Link) { + const computed = link.dep.computed + // computed getting its first subscriber + // enable tracking + lazily subscribe to all its deps + if (computed && !link.dep.subs) { + computed.flags |= EffectFlags.TRACKING | EffectFlags.DIRTY + for (let l = computed.deps; l; l = l.nextDep) { + addSub(l) + } + } + + const currentTail = link.dep.subs + if (currentTail !== link) { + link.prevSub = currentTail + if (currentTail) currentTail.nextSub = link + } + link.dep.subs = link +} + +// The main WeakMap that stores {target -> key -> dep} connections. +// Conceptually, it's easier to think of a dependency as a Dep class +// which maintains a Set of subscribers, but we simply store them as +// raw Maps to reduce memory overhead. +type KeyToDepMap = Map +const targetMap = new WeakMap() + +export const ITERATE_KEY = Symbol(__DEV__ ? 'iterate' : '') +export const MAP_KEY_ITERATE_KEY = Symbol(__DEV__ ? 'Map iterate' : '') + +/** + * Tracks access to a reactive property. + * + * This will check which effect is running at the moment and record it as dep + * which records all effects that depend on the reactive property. + * + * @param target - Object holding the reactive property. + * @param type - Defines the type of access to the reactive property. + * @param key - Identifier of the reactive property to track. + */ +export function track(target: object, type: TrackOpTypes, key: unknown) { + if (shouldTrack && activeSub) { + let depsMap = targetMap.get(target) + if (!depsMap) { + targetMap.set(target, (depsMap = new Map())) + } + let dep = depsMap.get(key) + if (!dep) { + depsMap.set(key, (dep = new Dep())) + } + if (__DEV__) { + dep.track({ + target, + type, + key, + }) + } else { + dep.track() + } + } +} + +/** + * Finds all deps associated with the target (or a specific property) and + * triggers the effects stored within. + * + * @param target - The reactive object. + * @param type - Defines the type of the operation that needs to trigger effects. + * @param key - Can be used to target a specific reactive property in the target object. + */ +export function trigger( + target: object, + type: TriggerOpTypes, + key?: unknown, + newValue?: unknown, + oldValue?: unknown, + oldTarget?: Map | Set, +) { + const depsMap = targetMap.get(target) + if (!depsMap) { + // never been tracked + globalVersion++ + return + } + + let deps: Dep[] = [] + if (type === TriggerOpTypes.CLEAR) { + // collection being cleared + // trigger all effects for target + deps = [...depsMap.values()] + } else if (key === 'length' && isArray(target)) { + const newLength = Number(newValue) + depsMap.forEach((dep, key) => { + if (key === 'length' || (!isSymbol(key) && key >= newLength)) { + deps.push(dep) + } + }) + } else { + const push = (dep: Dep | undefined) => dep && deps.push(dep) + + // schedule runs for SET | ADD | DELETE + if (key !== void 0) { + push(depsMap.get(key)) + } + + // also run for iteration key on ADD | DELETE | Map.SET + switch (type) { + case TriggerOpTypes.ADD: + if (!isArray(target)) { + push(depsMap.get(ITERATE_KEY)) + if (isMap(target)) { + push(depsMap.get(MAP_KEY_ITERATE_KEY)) + } + } else if (isIntegerKey(key)) { + // new index added to array -> length changes + push(depsMap.get('length')) + } + break + case TriggerOpTypes.DELETE: + if (!isArray(target)) { + push(depsMap.get(ITERATE_KEY)) + if (isMap(target)) { + push(depsMap.get(MAP_KEY_ITERATE_KEY)) + } + } + break + case TriggerOpTypes.SET: + if (isMap(target)) { + push(depsMap.get(ITERATE_KEY)) + } + break + } + } + + startBatch() + for (const dep of deps) { + if (__DEV__) { + dep.trigger({ + target, + type, + key, + newValue, + oldValue, + oldTarget, + }) + } else { + dep.trigger() + } + } + endBatch() } -export const createDep = ( - cleanup: () => void, - computed?: ComputedRefImpl, -): Dep => { - const dep = new Map() as Dep - dep.cleanup = cleanup - dep.computed = computed - return dep +/** + * Test only + */ +export function getDepFromReactive(object: any, key: string | number | symbol) { + return targetMap.get(object)?.get(key) } diff --git a/packages/reactivity/src/effect.ts b/packages/reactivity/src/effect.ts index ca90544c0de..5a4d05268dc 100644 --- a/packages/reactivity/src/effect.ts +++ b/packages/reactivity/src/effect.ts @@ -1,17 +1,14 @@ -import { NOOP, extend } from '@vue/shared' +import { extend, hasChanged } from '@vue/shared' import type { ComputedRefImpl } from './computed' -import { - DirtyLevels, - type TrackOpTypes, - type TriggerOpTypes, -} from './constants' -import type { Dep } from './dep' -import { type EffectScope, recordEffectScope } from './effectScope' +import type { TrackOpTypes, TriggerOpTypes } from './constants' +import { type Dep, globalVersion } from './dep' +import { recordEffectScope } from './effectScope' +import { warn } from './warning' export type EffectScheduler = (...args: any[]) => any export type DebuggerEvent = { - effect: ReactiveEffect + effect: Subscriber } & DebuggerEventExtraInfo export type DebuggerEventExtraInfo = { @@ -23,156 +20,398 @@ export type DebuggerEventExtraInfo = { oldTarget?: Map | Set } -export let activeEffect: ReactiveEffect | undefined +export interface DebuggerOptions { + onTrack?: (event: DebuggerEvent) => void + onTrigger?: (event: DebuggerEvent) => void +} -export class ReactiveEffect { - active = true - deps: Dep[] = [] +export interface ReactiveEffectOptions extends DebuggerOptions { + scheduler?: EffectScheduler + allowRecurse?: boolean + onStop?: () => void +} +export interface ReactiveEffectRunner { + (): T + effect: ReactiveEffect +} + +export let activeSub: Subscriber | undefined + +export enum EffectFlags { + ACTIVE = 1 << 0, + RUNNING = 1 << 1, + TRACKING = 1 << 2, + NOTIFIED = 1 << 3, + DIRTY = 1 << 4, + ALLOW_RECURSE = 1 << 5, + NO_BATCH = 1 << 6, +} + +/** + * Subscriber is a type that tracks (or subscribes to) a list of deps. + */ +export interface Subscriber extends DebuggerOptions { /** - * Can be attached after creation + * Head of the doubly linked list representing the deps * @internal */ - computed?: ComputedRefImpl + deps?: Link /** + * Tail of the same list * @internal */ - allowRecurse?: boolean + depsTail?: Link + /** + * @internal + */ + flags: EffectFlags + /** + * @internal + */ + notify(): void +} - onStop?: () => void - // dev only - onTrack?: (event: DebuggerEvent) => void - // dev only - onTrigger?: (event: DebuggerEvent) => void +/** + * Represents a link between a source (Dep) and a subscriber (Effect or Computed). + * Deps and subs have a many-to-many relationship - each link between a + * dep and a sub is represented by a Link instance. + * + * A Link is also a node in two doubly-linked lists - one for the associated + * sub to track all its deps, and one for the associated dep to track all its + * subs. + * + * @internal + */ +export interface Link { + dep: Dep + sub: Subscriber + + /** + * - Before each effect run, all previous dep links' version are reset to -1 + * - During the run, a link's version is synced with the source dep on access + * - After the run, links with version -1 (that were never used) are cleaned + * up + */ + version: number + /** + * Pointers for doubly-linked lists + */ + nextDep?: Link + prevDep?: Link + + nextSub?: Link + prevSub?: Link + + prevActiveLink?: Link +} + +export class ReactiveEffect + implements Subscriber, ReactiveEffectOptions +{ /** * @internal */ - _dirtyLevel = DirtyLevels.Dirty + deps?: Link = undefined /** * @internal */ - _trackId = 0 + depsTail?: Link = undefined /** * @internal */ - _runnings = 0 + flags: EffectFlags = EffectFlags.ACTIVE | EffectFlags.TRACKING /** * @internal */ - _shouldSchedule = false + nextEffect?: ReactiveEffect = undefined /** * @internal */ - _depsLength = 0 + allowRecurse?: boolean - constructor( - public fn: () => T, - public trigger: () => void, - public scheduler?: EffectScheduler, - scope?: EffectScope, - ) { - recordEffectScope(this, scope) - } + scheduler?: EffectScheduler = undefined + onStop?: () => void + onTrack?: (event: DebuggerEvent) => void + onTrigger?: (event: DebuggerEvent) => void - public get dirty() { - if ( - this._dirtyLevel === DirtyLevels.MaybeDirty_ComputedSideEffect || - this._dirtyLevel === DirtyLevels.MaybeDirty - ) { - this._dirtyLevel = DirtyLevels.QueryingDirty - pauseTracking() - for (let i = 0; i < this._depsLength; i++) { - const dep = this.deps[i] - if (dep.computed) { - triggerComputed(dep.computed) - if (this._dirtyLevel >= DirtyLevels.Dirty) { - break - } - } - } - if (this._dirtyLevel === DirtyLevels.QueryingDirty) { - this._dirtyLevel = DirtyLevels.NotDirty - } - resetTracking() - } - return this._dirtyLevel >= DirtyLevels.Dirty + constructor(public fn: () => T) { + recordEffectScope(this) } - public set dirty(v) { - this._dirtyLevel = v ? DirtyLevels.Dirty : DirtyLevels.NotDirty + /** + * @internal + */ + notify() { + if (this.flags & EffectFlags.RUNNING && !this.allowRecurse) { + return + } + if (this.flags & EffectFlags.NO_BATCH) { + return this.trigger() + } + if (!(this.flags & EffectFlags.NOTIFIED)) { + this.flags |= EffectFlags.NOTIFIED + this.nextEffect = batchedEffect + batchedEffect = this + } } run() { - this._dirtyLevel = DirtyLevels.NotDirty - if (!this.active) { + // TODO cleanupEffect + + if (!(this.flags & EffectFlags.ACTIVE)) { + // stopped during cleanup return this.fn() } - let lastShouldTrack = shouldTrack - let lastEffect = activeEffect + + this.flags |= EffectFlags.RUNNING + prepareDeps(this) + const prevEffect = activeSub + const prevShouldTrack = shouldTrack + activeSub = this + shouldTrack = true + try { - shouldTrack = true - activeEffect = this - this._runnings++ - preCleanupEffect(this) return this.fn() } finally { - postCleanupEffect(this) - this._runnings-- - activeEffect = lastEffect - shouldTrack = lastShouldTrack + if (__DEV__ && activeSub !== this) { + warn( + 'Active effect was not restored correctly - ' + + 'this is likely a Vue internal bug.', + ) + } + cleanupDeps(this) + activeSub = prevEffect + shouldTrack = prevShouldTrack + this.flags &= ~EffectFlags.RUNNING } } stop() { - if (this.active) { - preCleanupEffect(this) - postCleanupEffect(this) - this.onStop?.() - this.active = false + if (this.flags & EffectFlags.ACTIVE) { + for (let link = this.deps; link; link = link.nextDep) { + removeSub(link) + } + this.deps = this.depsTail = undefined + this.onStop && this.onStop() + this.flags &= ~EffectFlags.ACTIVE + } + } + + trigger() { + if (this.scheduler) { + this.scheduler() + } else { + this.runIfDirty() + } + } + + /** + * @internal + */ + runIfDirty() { + if (isDirty(this)) { + this.run() + } + } + + get dirty() { + return isDirty(this) + } +} + +let batchDepth = 0 +let batchedEffect: ReactiveEffect | undefined + +/** + * @internal + */ +export function startBatch() { + batchDepth++ +} + +/** + * Run batched effects when all batches have ended + * @internal + */ +export function endBatch() { + if (batchDepth > 1) { + batchDepth-- + return + } + + let error: unknown + while (batchedEffect) { + let e: ReactiveEffect | undefined = batchedEffect + batchedEffect = undefined + while (e) { + const next: ReactiveEffect | undefined = e.nextEffect + e.nextEffect = undefined + e.flags &= ~EffectFlags.NOTIFIED + if (e.flags & EffectFlags.ACTIVE) { + try { + e.trigger() + } catch (err) { + if (!error) error = err + } + } + e = next } } + + batchDepth-- + if (error) throw error } -function triggerComputed(computed: ComputedRefImpl) { - return computed.value +function prepareDeps(sub: Subscriber) { + // Prepare deps for tracking, starting from the head + for (let link = sub.deps; link; link = link.nextDep) { + // set all previous deps' (if any) version to -1 so that we can track + // which ones are unused after the run + link.version = -1 + // store previous active sub if link was being used in another context + link.prevActiveLink = link.dep.activeLink + link.dep.activeLink = link + } } -function preCleanupEffect(effect: ReactiveEffect) { - effect._trackId++ - effect._depsLength = 0 +function cleanupDeps(sub: Subscriber) { + // Cleanup unsued deps + let head + let tail = sub.depsTail + for (let link = tail; link; link = link.prevDep) { + if (link.version === -1) { + if (link === tail) tail = link.prevDep + // unused - remove it from the dep's subscribing effect list + removeSub(link) + // also remove it from this effect's dep list + removeDep(link) + } else { + // The new head is the last node seen which wasn't removed + // from the doubly-linked list + head = link + } + + // restore previous active link if any + link.dep.activeLink = link.prevActiveLink + link.prevActiveLink = undefined + } + // set the new head & tail + sub.deps = head + sub.depsTail = tail } -function postCleanupEffect(effect: ReactiveEffect) { - if (effect.deps.length > effect._depsLength) { - for (let i = effect._depsLength; i < effect.deps.length; i++) { - cleanupDepEffect(effect.deps[i], effect) +function isDirty(sub: Subscriber): boolean { + for (let link = sub.deps; link; link = link.nextDep) { + if ( + link.dep.version !== link.version || + (link.dep.computed && refreshComputed(link.dep.computed) === false) || + link.dep.version !== link.version + ) { + return true } - effect.deps.length = effect._depsLength } + // @ts-expect-error only for backwards compatibility where libs manually set + // this flag - e.g. Pinia's testing module + if (sub._dirty) { + return true + } + return false } -function cleanupDepEffect(dep: Dep, effect: ReactiveEffect) { - const trackId = dep.get(effect) - if (trackId !== undefined && effect._trackId !== trackId) { - dep.delete(effect) - if (dep.size === 0) { - dep.cleanup() +/** + * Returning false indicates the refresh failed + * @internal + */ +export function refreshComputed(computed: ComputedRefImpl) { + if (computed.flags & EffectFlags.RUNNING) { + return false + } + if ( + computed.flags & EffectFlags.TRACKING && + !(computed.flags & EffectFlags.DIRTY) + ) { + return + } + computed.flags &= ~EffectFlags.DIRTY + + // Global version fast path when no reactive changes has happened since + // last refresh. + if (computed.globalVersion === globalVersion) { + return + } + computed.globalVersion = globalVersion + + const dep = computed.dep + computed.flags |= EffectFlags.RUNNING + // In SSR there will be no render effect, so the computed has no subscriber + // and therefore tracks no deps, thus we cannot rely on the dirty check. + // Instead, computed always re-evaluate and relies on the globalVersion + // fast path above for caching. + if (dep.version > 0 && !computed.isSSR && !isDirty(computed)) { + computed.flags &= ~EffectFlags.RUNNING + return + } + + const prevSub = activeSub + const prevShouldTrack = shouldTrack + activeSub = computed + shouldTrack = true + + try { + prepareDeps(computed) + const value = computed.fn() + if (dep.version === 0 || hasChanged(value, computed._value)) { + computed._value = value + dep.version++ } + } catch (err) { + dep.version++ } + + activeSub = prevSub + shouldTrack = prevShouldTrack + cleanupDeps(computed) + computed.flags &= ~EffectFlags.RUNNING } -export interface DebuggerOptions { - onTrack?: (event: DebuggerEvent) => void - onTrigger?: (event: DebuggerEvent) => void +function removeSub(link: Link) { + const { dep, prevSub, nextSub } = link + if (prevSub) { + prevSub.nextSub = nextSub + link.prevSub = undefined + } + if (nextSub) { + nextSub.prevSub = prevSub + link.nextSub = undefined + } + if (dep.subs === link) { + // was previous tail, point new tail to prev + dep.subs = prevSub + } + + if (!dep.subs && dep.computed) { + // last subscriber removed + // if computed, unsubscribe it from all its deps so this computed and its + // value can be GCed + dep.computed.flags &= ~EffectFlags.TRACKING + for (let l = dep.computed.deps; l; l = l.nextDep) { + removeSub(l) + } + } } -export interface ReactiveEffectOptions extends DebuggerOptions { - lazy?: boolean - scheduler?: EffectScheduler - scope?: EffectScope - allowRecurse?: boolean - onStop?: () => void +function removeDep(link: Link) { + const { prevDep, nextDep } = link + if (prevDep) { + prevDep.nextDep = nextDep + link.prevDep = undefined + } + if (nextDep) { + nextDep.prevDep = prevDep + link.nextDep = undefined + } } export interface ReactiveEffectRunner { @@ -180,38 +419,26 @@ export interface ReactiveEffectRunner { effect: ReactiveEffect } -/** - * Registers the given function to track reactive updates. - * - * The given function will be run once immediately. Every time any reactive - * property that's accessed within it gets updated, the function will run again. - * - * @param fn - The function that will track reactive updates. - * @param options - Allows to control the effect's behaviour. - * @returns A runner that can be used to control the effect after creation. - */ export function effect( fn: () => T, options?: ReactiveEffectOptions, -): ReactiveEffectRunner { +): ReactiveEffectRunner { if ((fn as ReactiveEffectRunner).effect instanceof ReactiveEffect) { fn = (fn as ReactiveEffectRunner).effect.fn } - const _effect = new ReactiveEffect(fn, NOOP, () => { - if (_effect.dirty) { - _effect.run() - } - }) + const e = new ReactiveEffect(fn) if (options) { - extend(_effect, options) - if (options.scope) recordEffectScope(_effect, options.scope) + extend(e, options) } - if (!options || !options.lazy) { - _effect.run() + try { + e.run() + } catch (err) { + e.stop() + throw err } - const runner = _effect.run.bind(_effect) as ReactiveEffectRunner - runner.effect = _effect + const runner = e.run.bind(e) as ReactiveEffectRunner + runner.effect = e return runner } @@ -224,9 +451,10 @@ export function stop(runner: ReactiveEffectRunner) { runner.effect.stop() } +/** + * @internal + */ export let shouldTrack = true -export let pauseScheduleStack = 0 - const trackStack: boolean[] = [] /** @@ -252,76 +480,3 @@ export function resetTracking() { const last = trackStack.pop() shouldTrack = last === undefined ? true : last } - -export function pauseScheduling() { - pauseScheduleStack++ -} - -export function resetScheduling() { - pauseScheduleStack-- - while (!pauseScheduleStack && queueEffectSchedulers.length) { - queueEffectSchedulers.shift()!() - } -} - -export function trackEffect( - effect: ReactiveEffect, - dep: Dep, - debuggerEventExtraInfo?: DebuggerEventExtraInfo, -) { - if (dep.get(effect) !== effect._trackId) { - dep.set(effect, effect._trackId) - const oldDep = effect.deps[effect._depsLength] - if (oldDep !== dep) { - if (oldDep) { - cleanupDepEffect(oldDep, effect) - } - effect.deps[effect._depsLength++] = dep - } else { - effect._depsLength++ - } - if (__DEV__) { - effect.onTrack?.(extend({ effect }, debuggerEventExtraInfo!)) - } - } -} - -const queueEffectSchedulers: EffectScheduler[] = [] - -export function triggerEffects( - dep: Dep, - dirtyLevel: DirtyLevels, - debuggerEventExtraInfo?: DebuggerEventExtraInfo, -) { - pauseScheduling() - for (const effect of dep.keys()) { - // dep.get(effect) is very expensive, we need to calculate it lazily and reuse the result - let tracking: boolean | undefined - if ( - effect._dirtyLevel < dirtyLevel && - (tracking ??= dep.get(effect) === effect._trackId) - ) { - effect._shouldSchedule ||= effect._dirtyLevel === DirtyLevels.NotDirty - effect._dirtyLevel = dirtyLevel - } - if ( - effect._shouldSchedule && - (tracking ??= dep.get(effect) === effect._trackId) - ) { - if (__DEV__) { - effect.onTrigger?.(extend({ effect }, debuggerEventExtraInfo)) - } - effect.trigger() - if ( - (!effect._runnings || effect.allowRecurse) && - effect._dirtyLevel !== DirtyLevels.MaybeDirty_ComputedSideEffect - ) { - effect._shouldSchedule = false - if (effect.scheduler) { - queueEffectSchedulers.push(effect.scheduler) - } - } - } - } - resetScheduling() -} diff --git a/packages/reactivity/src/index.ts b/packages/reactivity/src/index.ts index 1c80fbc752b..40bdf7b1b04 100644 --- a/packages/reactivity/src/index.ts +++ b/packages/reactivity/src/index.ts @@ -44,16 +44,14 @@ export { type ComputedGetter, type ComputedSetter, } from './computed' -export { deferredComputed } from './deferredComputed' export { effect, stop, enableTracking, pauseTracking, resetTracking, - pauseScheduling, - resetScheduling, ReactiveEffect, + EffectFlags, type ReactiveEffectRunner, type ReactiveEffectOptions, type EffectScheduler, @@ -61,7 +59,7 @@ export { type DebuggerEvent, type DebuggerEventExtraInfo, } from './effect' -export { trigger, track, ITERATE_KEY } from './reactiveEffect' +export { trigger, track, ITERATE_KEY } from './dep' export { effectScope, EffectScope, diff --git a/packages/reactivity/src/reactiveEffect.ts b/packages/reactivity/src/reactiveEffect.ts deleted file mode 100644 index 6bf0e75115a..00000000000 --- a/packages/reactivity/src/reactiveEffect.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { isArray, isIntegerKey, isMap, isSymbol } from '@vue/shared' -import { DirtyLevels, type TrackOpTypes, TriggerOpTypes } from './constants' -import { type Dep, createDep } from './dep' -import { - activeEffect, - pauseScheduling, - resetScheduling, - shouldTrack, - trackEffect, - triggerEffects, -} from './effect' - -// The main WeakMap that stores {target -> key -> dep} connections. -// Conceptually, it's easier to think of a dependency as a Dep class -// which maintains a Set of subscribers, but we simply store them as -// raw Maps to reduce memory overhead. -type KeyToDepMap = Map -const targetMap = new WeakMap() - -export const ITERATE_KEY = Symbol(__DEV__ ? 'iterate' : '') -export const MAP_KEY_ITERATE_KEY = Symbol(__DEV__ ? 'Map key iterate' : '') - -/** - * Tracks access to a reactive property. - * - * This will check which effect is running at the moment and record it as dep - * which records all effects that depend on the reactive property. - * - * @param target - Object holding the reactive property. - * @param type - Defines the type of access to the reactive property. - * @param key - Identifier of the reactive property to track. - */ -export function track(target: object, type: TrackOpTypes, key: unknown) { - if (shouldTrack && activeEffect) { - let depsMap = targetMap.get(target) - if (!depsMap) { - targetMap.set(target, (depsMap = new Map())) - } - let dep = depsMap.get(key) - if (!dep) { - depsMap.set(key, (dep = createDep(() => depsMap!.delete(key)))) - } - trackEffect( - activeEffect, - dep, - __DEV__ - ? { - target, - type, - key, - } - : void 0, - ) - } -} - -/** - * Finds all deps associated with the target (or a specific property) and - * triggers the effects stored within. - * - * @param target - The reactive object. - * @param type - Defines the type of the operation that needs to trigger effects. - * @param key - Can be used to target a specific reactive property in the target object. - */ -export function trigger( - target: object, - type: TriggerOpTypes, - key?: unknown, - newValue?: unknown, - oldValue?: unknown, - oldTarget?: Map | Set, -) { - const depsMap = targetMap.get(target) - if (!depsMap) { - // never been tracked - return - } - - let deps: (Dep | undefined)[] = [] - if (type === TriggerOpTypes.CLEAR) { - // collection being cleared - // trigger all effects for target - deps = [...depsMap.values()] - } else if (key === 'length' && isArray(target)) { - const newLength = Number(newValue) - depsMap.forEach((dep, key) => { - if (key === 'length' || (!isSymbol(key) && key >= newLength)) { - deps.push(dep) - } - }) - } else { - // schedule runs for SET | ADD | DELETE - if (key !== void 0) { - deps.push(depsMap.get(key)) - } - - // also run for iteration key on ADD | DELETE | Map.SET - switch (type) { - case TriggerOpTypes.ADD: - if (!isArray(target)) { - deps.push(depsMap.get(ITERATE_KEY)) - if (isMap(target)) { - deps.push(depsMap.get(MAP_KEY_ITERATE_KEY)) - } - } else if (isIntegerKey(key)) { - // new index added to array -> length changes - deps.push(depsMap.get('length')) - } - break - case TriggerOpTypes.DELETE: - if (!isArray(target)) { - deps.push(depsMap.get(ITERATE_KEY)) - if (isMap(target)) { - deps.push(depsMap.get(MAP_KEY_ITERATE_KEY)) - } - } - break - case TriggerOpTypes.SET: - if (isMap(target)) { - deps.push(depsMap.get(ITERATE_KEY)) - } - break - } - } - - pauseScheduling() - for (const dep of deps) { - if (dep) { - triggerEffects( - dep, - DirtyLevels.Dirty, - __DEV__ - ? { - target, - type, - key, - newValue, - oldValue, - oldTarget, - } - : void 0, - ) - } - } - resetScheduling() -} - -export function getDepFromReactive(object: any, key: string | number | symbol) { - return targetMap.get(object)?.get(key) -} diff --git a/packages/reactivity/src/ref.ts b/packages/reactivity/src/ref.ts index 1b9d60ef06b..bfde3b787e3 100644 --- a/packages/reactivity/src/ref.ts +++ b/packages/reactivity/src/ref.ts @@ -1,11 +1,3 @@ -import type { ComputedRef } from './computed' -import { - activeEffect, - shouldTrack, - trackEffect, - triggerEffects, -} from './effect' -import { DirtyLevels, TrackOpTypes, TriggerOpTypes } from './constants' import { type IfAny, hasChanged, @@ -13,7 +5,9 @@ import { isFunction, isObject, } from '@vue/shared' +import { Dep, getDepFromReactive } from './dep' import { + type ShallowReactiveMarker, isProxy, isReactive, isReadonly, @@ -21,10 +15,8 @@ import { toRaw, toReactive, } from './reactive' -import type { ShallowReactiveMarker } from './reactive' -import { type Dep, createDep } from './dep' -import { ComputedRefImpl } from './computed' -import { getDepFromReactive } from './reactiveEffect' +import type { ComputedRef } from './computed' +import { TrackOpTypes, TriggerOpTypes } from './constants' declare const RefSymbol: unique symbol export declare const RawSymbol: unique symbol @@ -39,54 +31,6 @@ export interface Ref { [RefSymbol]: true } -type RefBase = { - dep?: Dep - value: T -} - -export function trackRefValue(ref: RefBase) { - if (shouldTrack && activeEffect) { - ref = toRaw(ref) - trackEffect( - activeEffect, - (ref.dep ??= createDep( - () => (ref.dep = undefined), - ref instanceof ComputedRefImpl ? ref : undefined, - )), - __DEV__ - ? { - target: ref, - type: TrackOpTypes.GET, - key: 'value', - } - : void 0, - ) - } -} - -export function triggerRefValue( - ref: RefBase, - dirtyLevel: DirtyLevels = DirtyLevels.Dirty, - newVal?: any, -) { - ref = toRaw(ref) - const dep = ref.dep - if (dep) { - triggerEffects( - dep, - dirtyLevel, - __DEV__ - ? { - target: ref, - type: TriggerOpTypes.SET, - key: 'value', - newValue: newVal, - } - : void 0, - ) - } -} - /** * Checks if a value is a ref object. * @@ -95,7 +39,7 @@ export function triggerRefValue( */ export function isRef(r: Ref | unknown): r is Ref export function isRef(r: any): r is Ref { - return !!(r && r.__v_isRef === true) + return r ? r.__v_isRef === true : false } /** @@ -151,11 +95,15 @@ function createRef(rawValue: unknown, shallow: boolean) { return new RefImpl(rawValue, shallow) } -class RefImpl { - private _value: T +/** + * @internal + */ +class RefImpl { + _value: T private _rawValue: T - public dep?: Dep = undefined + dep: Dep = new Dep() + public readonly __v_isRef = true constructor( @@ -167,18 +115,37 @@ class RefImpl { } get value() { - trackRefValue(this) + if (__DEV__) { + this.dep.track({ + target: this, + type: TrackOpTypes.GET, + key: 'value', + }) + } else { + this.dep.track() + } return this._value } - set value(newVal) { + set value(newValue) { + const oldValue = this._rawValue const useDirectValue = - this.__v_isShallow || isShallow(newVal) || isReadonly(newVal) - newVal = useDirectValue ? newVal : toRaw(newVal) - if (hasChanged(newVal, this._rawValue)) { - this._rawValue = newVal - this._value = useDirectValue ? newVal : toReactive(newVal) - triggerRefValue(this, DirtyLevels.Dirty, newVal) + this.__v_isShallow || isShallow(newValue) || isReadonly(newValue) + newValue = useDirectValue ? newValue : toRaw(newValue) + if (hasChanged(newValue, oldValue)) { + this._rawValue = newValue + this._value = useDirectValue ? newValue : toReactive(newValue) + if (__DEV__) { + this.dep.trigger({ + target: this, + type: TriggerOpTypes.SET, + key: 'value', + newValue, + oldValue, + }) + } else { + this.dep.trigger() + } } } } @@ -209,7 +176,16 @@ class RefImpl { * @see {@link https://vuejs.org/api/reactivity-advanced.html#triggerref} */ export function triggerRef(ref: Ref) { - triggerRefValue(ref, DirtyLevels.Dirty, __DEV__ ? ref.value : void 0) + if (__DEV__) { + ;(ref as unknown as RefImpl).dep.trigger({ + target: ref, + type: TriggerOpTypes.SET, + key: 'value', + newValue: (ref as unknown as RefImpl)._value, + }) + } else { + ;(ref as unknown as RefImpl).dep.trigger() + } } export type MaybeRef = T | Ref @@ -295,7 +271,7 @@ export type CustomRefFactory = ( } class CustomRefImpl { - public dep?: Dep = undefined + public dep: Dep private readonly _get: ReturnType>['get'] private readonly _set: ReturnType>['set'] @@ -303,10 +279,8 @@ class CustomRefImpl { public readonly __v_isRef = true constructor(factory: CustomRefFactory) { - const { get, set } = factory( - () => trackRefValue(this), - () => triggerRefValue(this), - ) + const dep = (this.dep = new Dep()) + const { get, set } = factory(dep.track.bind(dep), dep.trigger.bind(dep)) this._get = get this._set = set } diff --git a/packages/runtime-core/__tests__/apiSetupHelpers.spec.ts b/packages/runtime-core/__tests__/apiSetupHelpers.spec.ts index 04e9c1c86db..30c8951f405 100644 --- a/packages/runtime-core/__tests__/apiSetupHelpers.spec.ts +++ b/packages/runtime-core/__tests__/apiSetupHelpers.spec.ts @@ -1,6 +1,5 @@ import { type ComponentInternalInstance, - type ComputedRef, type SetupContext, Suspense, computed, @@ -26,6 +25,8 @@ import { withAsyncContext, withDefaults, } from '../src/apiSetupHelpers' +import type { ComputedRefImpl } from '../../reactivity/src/computed' +import { EffectFlags, type ReactiveEffectRunner, effect } from '@vue/reactivity' describe('SFC