From 14ca881a1ba6ad887d5ffc6ce3b7f8461252afee Mon Sep 17 00:00:00 2001 From: Evan You Date: Tue, 20 Jul 2021 17:39:19 -0400 Subject: [PATCH] feat(reactivity): deferredComputed Note: this is not exposed as part of Vue API, only as a lower-level API specific to @vue/reactivity --- .../__tests__/deferredComputed.spec.ts | 185 ++++++++++++++++++ packages/reactivity/src/deferredComputed.ts | 88 +++++++++ packages/reactivity/src/index.ts | 1 + 3 files changed, 274 insertions(+) create mode 100644 packages/reactivity/__tests__/deferredComputed.spec.ts create mode 100644 packages/reactivity/src/deferredComputed.ts diff --git a/packages/reactivity/__tests__/deferredComputed.spec.ts b/packages/reactivity/__tests__/deferredComputed.spec.ts new file mode 100644 index 00000000..39402801 --- /dev/null +++ b/packages/reactivity/__tests__/deferredComputed.spec.ts @@ -0,0 +1,185 @@ +import { computed, deferredComputed, effect, ref } from '../src' + +describe('deferred computed', () => { + const tick = Promise.resolve() + + test('should only trigger once on multiple mutations', async () => { + const src = ref(0) + const c = deferredComputed(() => src.value) + const spy = jest.fn() + effect(() => { + spy(c.value) + }) + expect(spy).toHaveBeenCalledTimes(1) + src.value = 1 + src.value = 2 + src.value = 3 + // not called yet + expect(spy).toHaveBeenCalledTimes(1) + await tick + // should only trigger once + expect(spy).toHaveBeenCalledTimes(2) + expect(spy).toHaveBeenCalledWith(c.value) + }) + + test('should not trigger if value did not change', async () => { + const src = ref(0) + const c = deferredComputed(() => src.value % 2) + const spy = jest.fn() + effect(() => { + spy(c.value) + }) + expect(spy).toHaveBeenCalledTimes(1) + src.value = 1 + src.value = 2 + + await tick + // should not trigger + expect(spy).toHaveBeenCalledTimes(1) + + src.value = 3 + src.value = 4 + src.value = 5 + await tick + // should trigger because latest value changes + expect(spy).toHaveBeenCalledTimes(2) + }) + + test('chained computed trigger', async () => { + const effectSpy = jest.fn() + const c1Spy = jest.fn() + const c2Spy = jest.fn() + + const src = ref(0) + const c1 = deferredComputed(() => { + 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 + await tick + expect(c1Spy).toHaveBeenCalledTimes(2) + expect(c2Spy).toHaveBeenCalledTimes(2) + expect(effectSpy).toHaveBeenCalledTimes(2) + }) + + test('chained computed avoid re-compute', async () => { + const effectSpy = jest.fn() + const c1Spy = jest.fn() + const c2Spy = jest.fn() + + const src = ref(0) + const c1 = deferredComputed(() => { + 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 + await tick + // c1 should re-compute once. + expect(c1Spy).toHaveBeenCalledTimes(2) + // 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', async () => { + const effectSpy = jest.fn() + const c1Spy = jest.fn() + const c2Spy = jest.fn() + + const src = ref(0) + const c1 = deferredComputed(() => { + c1Spy() + return src.value % 2 + }) + const c2 = deferredComputed(() => { + 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', async () => { + const effectSpy = jest.fn() + const c1Spy = jest.fn() + const c2Spy = jest.fn() + + const src = ref(0) + const c1 = deferredComputed(() => { + c1Spy() + return src.value % 2 + }) + const c2 = deferredComputed(() => { + c2Spy() + return c1.value + 1 + }) + + effect(() => { + effectSpy(c2.value) + }) + expect(effectSpy).toHaveBeenCalledTimes(1) + + src.value = 1 + // sync access c2 + c2.value + await tick + expect(effectSpy).toHaveBeenCalledTimes(2) + }) + + test('should not compute if deactivated before scheduler is called', async () => { + const c1Spy = jest.fn() + const src = ref(0) + const c1 = deferredComputed(() => { + c1Spy() + return src.value % 2 + }) + effect(() => c1.value) + expect(c1Spy).toHaveBeenCalledTimes(1) + + c1.effect.stop() + // trigger + src.value++ + await tick + expect(c1Spy).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/reactivity/src/deferredComputed.ts b/packages/reactivity/src/deferredComputed.ts new file mode 100644 index 00000000..1cd97c74 --- /dev/null +++ b/packages/reactivity/src/deferredComputed.ts @@ -0,0 +1,88 @@ +import { Dep } from './dep' +import { ReactiveEffect } from './effect' +import { ComputedGetter, ComputedRef } from './computed' +import { ReactiveFlags, toRaw } from './reactive' +import { trackRefValue, triggerRefValue } from './ref' + +const tick = Promise.resolve() +const queue: any[] = [] +let queued = false + +const scheduler = (fn: any) => { + queue.push(fn) + if (!queued) { + queued = true + tick.then(flush) + } +} + +const flush = () => { + for (let i = 0; i < queue.length; i++) { + queue[i]() + } + queue.length = 0 + queued = false +} + +class DeferredComputedRefImpl { + public dep?: Dep = undefined + + private _value!: T + private _dirty = true + public readonly effect: ReactiveEffect + + public readonly __v_isRef = true + public readonly [ReactiveFlags.IS_READONLY] = true + + constructor(getter: ComputedGetter) { + let compareTarget: any + let hasCompareTarget = false + let scheduled = false + this.effect = new ReactiveEffect(getter, (computedTrigger?: boolean) => { + if (this.dep) { + if (computedTrigger) { + compareTarget = this._value + hasCompareTarget = true + } else if (!scheduled) { + const valueToCompare = hasCompareTarget ? compareTarget : this._value + scheduled = true + hasCompareTarget = false + scheduler(() => { + if (this.effect.active && this._get() !== valueToCompare) { + triggerRefValue(this) + } + scheduled = false + }) + } + // chained upstream computeds are notified synchronously to ensure + // value invalidation in case of sync access; normal effects are + // deferred to be triggered in scheduler. + for (const e of this.dep) { + if (e.computed) { + e.scheduler!(true /* computedTrigger */) + } + } + } + this._dirty = true + }) + this.effect.computed = true + } + + private _get() { + if (this._dirty) { + this._dirty = false + return (this._value = this.effect.run()!) + } + return this._value + } + + get value() { + trackRefValue(this) + // the computed ref may get wrapped by other proxies e.g. readonly() #3376 + return toRaw(this)._get() + } +} + +export function deferredComputed(getter: () => T): ComputedRef { + return new DeferredComputedRefImpl(getter) as any +} diff --git a/packages/reactivity/src/index.ts b/packages/reactivity/src/index.ts index 7b62094c..d26e87de 100644 --- a/packages/reactivity/src/index.ts +++ b/packages/reactivity/src/index.ts @@ -36,6 +36,7 @@ export { ComputedGetter, ComputedSetter } from './computed' +export { deferredComputed } from './deferredComputed' export { effect, stop,