Skip to content

Commit 14ca881

Browse files
committed
feat(reactivity): deferredComputed
Note: this is not exposed as part of Vue API, only as a lower-level API specific to @vue/reactivity
1 parent d87d059 commit 14ca881

File tree

3 files changed

+274
-0
lines changed

3 files changed

+274
-0
lines changed
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import { computed, deferredComputed, effect, ref } from '../src'
2+
3+
describe('deferred computed', () => {
4+
const tick = Promise.resolve()
5+
6+
test('should only trigger once on multiple mutations', async () => {
7+
const src = ref(0)
8+
const c = deferredComputed(() => src.value)
9+
const spy = jest.fn()
10+
effect(() => {
11+
spy(c.value)
12+
})
13+
expect(spy).toHaveBeenCalledTimes(1)
14+
src.value = 1
15+
src.value = 2
16+
src.value = 3
17+
// not called yet
18+
expect(spy).toHaveBeenCalledTimes(1)
19+
await tick
20+
// should only trigger once
21+
expect(spy).toHaveBeenCalledTimes(2)
22+
expect(spy).toHaveBeenCalledWith(c.value)
23+
})
24+
25+
test('should not trigger if value did not change', async () => {
26+
const src = ref(0)
27+
const c = deferredComputed(() => src.value % 2)
28+
const spy = jest.fn()
29+
effect(() => {
30+
spy(c.value)
31+
})
32+
expect(spy).toHaveBeenCalledTimes(1)
33+
src.value = 1
34+
src.value = 2
35+
36+
await tick
37+
// should not trigger
38+
expect(spy).toHaveBeenCalledTimes(1)
39+
40+
src.value = 3
41+
src.value = 4
42+
src.value = 5
43+
await tick
44+
// should trigger because latest value changes
45+
expect(spy).toHaveBeenCalledTimes(2)
46+
})
47+
48+
test('chained computed trigger', async () => {
49+
const effectSpy = jest.fn()
50+
const c1Spy = jest.fn()
51+
const c2Spy = jest.fn()
52+
53+
const src = ref(0)
54+
const c1 = deferredComputed(() => {
55+
c1Spy()
56+
return src.value % 2
57+
})
58+
const c2 = computed(() => {
59+
c2Spy()
60+
return c1.value + 1
61+
})
62+
63+
effect(() => {
64+
effectSpy(c2.value)
65+
})
66+
67+
expect(c1Spy).toHaveBeenCalledTimes(1)
68+
expect(c2Spy).toHaveBeenCalledTimes(1)
69+
expect(effectSpy).toHaveBeenCalledTimes(1)
70+
71+
src.value = 1
72+
await tick
73+
expect(c1Spy).toHaveBeenCalledTimes(2)
74+
expect(c2Spy).toHaveBeenCalledTimes(2)
75+
expect(effectSpy).toHaveBeenCalledTimes(2)
76+
})
77+
78+
test('chained computed avoid re-compute', async () => {
79+
const effectSpy = jest.fn()
80+
const c1Spy = jest.fn()
81+
const c2Spy = jest.fn()
82+
83+
const src = ref(0)
84+
const c1 = deferredComputed(() => {
85+
c1Spy()
86+
return src.value % 2
87+
})
88+
const c2 = computed(() => {
89+
c2Spy()
90+
return c1.value + 1
91+
})
92+
93+
effect(() => {
94+
effectSpy(c2.value)
95+
})
96+
97+
expect(effectSpy).toHaveBeenCalledTimes(1)
98+
src.value = 2
99+
src.value = 4
100+
src.value = 6
101+
await tick
102+
// c1 should re-compute once.
103+
expect(c1Spy).toHaveBeenCalledTimes(2)
104+
// c2 should not have to re-compute because c1 did not change.
105+
expect(c2Spy).toHaveBeenCalledTimes(1)
106+
// effect should not trigger because c2 did not change.
107+
expect(effectSpy).toHaveBeenCalledTimes(1)
108+
})
109+
110+
test('chained computed value invalidation', async () => {
111+
const effectSpy = jest.fn()
112+
const c1Spy = jest.fn()
113+
const c2Spy = jest.fn()
114+
115+
const src = ref(0)
116+
const c1 = deferredComputed(() => {
117+
c1Spy()
118+
return src.value % 2
119+
})
120+
const c2 = deferredComputed(() => {
121+
c2Spy()
122+
return c1.value + 1
123+
})
124+
125+
effect(() => {
126+
effectSpy(c2.value)
127+
})
128+
129+
expect(effectSpy).toHaveBeenCalledTimes(1)
130+
expect(effectSpy).toHaveBeenCalledWith(1)
131+
expect(c2.value).toBe(1)
132+
133+
expect(c1Spy).toHaveBeenCalledTimes(1)
134+
expect(c2Spy).toHaveBeenCalledTimes(1)
135+
136+
src.value = 1
137+
// value should be available sync
138+
expect(c2.value).toBe(2)
139+
expect(c2Spy).toHaveBeenCalledTimes(2)
140+
})
141+
142+
test('sync access of invalidated chained computed should not prevent final effect from running', async () => {
143+
const effectSpy = jest.fn()
144+
const c1Spy = jest.fn()
145+
const c2Spy = jest.fn()
146+
147+
const src = ref(0)
148+
const c1 = deferredComputed(() => {
149+
c1Spy()
150+
return src.value % 2
151+
})
152+
const c2 = deferredComputed(() => {
153+
c2Spy()
154+
return c1.value + 1
155+
})
156+
157+
effect(() => {
158+
effectSpy(c2.value)
159+
})
160+
expect(effectSpy).toHaveBeenCalledTimes(1)
161+
162+
src.value = 1
163+
// sync access c2
164+
c2.value
165+
await tick
166+
expect(effectSpy).toHaveBeenCalledTimes(2)
167+
})
168+
169+
test('should not compute if deactivated before scheduler is called', async () => {
170+
const c1Spy = jest.fn()
171+
const src = ref(0)
172+
const c1 = deferredComputed(() => {
173+
c1Spy()
174+
return src.value % 2
175+
})
176+
effect(() => c1.value)
177+
expect(c1Spy).toHaveBeenCalledTimes(1)
178+
179+
c1.effect.stop()
180+
// trigger
181+
src.value++
182+
await tick
183+
expect(c1Spy).toHaveBeenCalledTimes(1)
184+
})
185+
})
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { Dep } from './dep'
2+
import { ReactiveEffect } from './effect'
3+
import { ComputedGetter, ComputedRef } from './computed'
4+
import { ReactiveFlags, toRaw } from './reactive'
5+
import { trackRefValue, triggerRefValue } from './ref'
6+
7+
const tick = Promise.resolve()
8+
const queue: any[] = []
9+
let queued = false
10+
11+
const scheduler = (fn: any) => {
12+
queue.push(fn)
13+
if (!queued) {
14+
queued = true
15+
tick.then(flush)
16+
}
17+
}
18+
19+
const flush = () => {
20+
for (let i = 0; i < queue.length; i++) {
21+
queue[i]()
22+
}
23+
queue.length = 0
24+
queued = false
25+
}
26+
27+
class DeferredComputedRefImpl<T> {
28+
public dep?: Dep = undefined
29+
30+
private _value!: T
31+
private _dirty = true
32+
public readonly effect: ReactiveEffect<T>
33+
34+
public readonly __v_isRef = true
35+
public readonly [ReactiveFlags.IS_READONLY] = true
36+
37+
constructor(getter: ComputedGetter<T>) {
38+
let compareTarget: any
39+
let hasCompareTarget = false
40+
let scheduled = false
41+
this.effect = new ReactiveEffect(getter, (computedTrigger?: boolean) => {
42+
if (this.dep) {
43+
if (computedTrigger) {
44+
compareTarget = this._value
45+
hasCompareTarget = true
46+
} else if (!scheduled) {
47+
const valueToCompare = hasCompareTarget ? compareTarget : this._value
48+
scheduled = true
49+
hasCompareTarget = false
50+
scheduler(() => {
51+
if (this.effect.active && this._get() !== valueToCompare) {
52+
triggerRefValue(this)
53+
}
54+
scheduled = false
55+
})
56+
}
57+
// chained upstream computeds are notified synchronously to ensure
58+
// value invalidation in case of sync access; normal effects are
59+
// deferred to be triggered in scheduler.
60+
for (const e of this.dep) {
61+
if (e.computed) {
62+
e.scheduler!(true /* computedTrigger */)
63+
}
64+
}
65+
}
66+
this._dirty = true
67+
})
68+
this.effect.computed = true
69+
}
70+
71+
private _get() {
72+
if (this._dirty) {
73+
this._dirty = false
74+
return (this._value = this.effect.run()!)
75+
}
76+
return this._value
77+
}
78+
79+
get value() {
80+
trackRefValue(this)
81+
// the computed ref may get wrapped by other proxies e.g. readonly() #3376
82+
return toRaw(this)._get()
83+
}
84+
}
85+
86+
export function deferredComputed<T>(getter: () => T): ComputedRef<T> {
87+
return new DeferredComputedRefImpl(getter) as any
88+
}

packages/reactivity/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export {
3636
ComputedGetter,
3737
ComputedSetter
3838
} from './computed'
39+
export { deferredComputed } from './deferredComputed'
3940
export {
4041
effect,
4142
stop,

0 commit comments

Comments
 (0)