fix(reactivity): ensure computed always expose value

fix #3099

Also changes the original fix for #910 by moving the fix from
reactivity to the scheduler
This commit is contained in:
Evan You 2021-05-27 20:53:21 -04:00
parent 32e21333dd
commit 03a7a73148
5 changed files with 52 additions and 42 deletions

View File

@ -193,4 +193,10 @@ describe('reactivity/computed', () => {
expect(isReadonly(z)).toBe(false) expect(isReadonly(z)).toBe(false)
expect(isReadonly(z.value.a)).toBe(false) expect(isReadonly(z.value.a)).toBe(false)
}) })
it('should expose value when stopped', () => {
const x = computed(() => 1)
stop(x.effect)
expect(x.value).toBe(1)
})
}) })

View File

@ -709,28 +709,6 @@ describe('reactivity/effect', () => {
expect(dummy).toBe(3) expect(dummy).toBe(3)
}) })
it('stop with scheduler', () => {
let dummy
const obj = reactive({ prop: 1 })
const queue: (() => void)[] = []
const runner = effect(
() => {
dummy = obj.prop
},
{
scheduler: e => queue.push(e)
}
)
obj.prop = 2
expect(dummy).toBe(1)
expect(queue.length).toBe(1)
stop(runner)
// a scheduled effect should not execute anymore after stopped
queue.forEach(e => e())
expect(dummy).toBe(1)
})
it('events: onStop', () => { it('events: onStop', () => {
const onStop = jest.fn() const onStop = jest.fn()
const runner = effect(() => {}, { const runner = effect(() => {}, {

View File

@ -26,6 +26,21 @@ export interface ReactiveEffectOptions {
onTrack?: (event: DebuggerEvent) => void onTrack?: (event: DebuggerEvent) => void
onTrigger?: (event: DebuggerEvent) => void onTrigger?: (event: DebuggerEvent) => void
onStop?: () => void onStop?: () => void
/**
* Indicates whether the job is allowed to recursively trigger itself when
* managed by the scheduler.
*
* By default, a job cannot trigger itself because some built-in method calls,
* e.g. Array.prototype.push actually performs reads as well (#1740) which
* can lead to confusing infinite loops.
* The allowed cases are component update functions and watch callbacks.
* Component update functions may update child component props, which in turn
* trigger flush: "pre" watch callbacks that mutates state that the parent
* relies on (#1801). Watch callbacks doesn't track its dependencies so if it
* triggers itself again, it's likely intentional and it is the user's
* responsibility to perform recursive state mutation that eventually
* stabilizes (#1727).
*/
allowRecurse?: boolean allowRecurse?: boolean
} }
@ -84,7 +99,7 @@ function createReactiveEffect<T = any>(
): ReactiveEffect<T> { ): ReactiveEffect<T> {
const effect = function reactiveEffect(): unknown { const effect = function reactiveEffect(): unknown {
if (!effect.active) { if (!effect.active) {
return options.scheduler ? undefined : fn() return fn()
} }
if (!effectStack.includes(effect)) { if (!effectStack.includes(effect)) {
cleanup(effect) cleanup(effect)

View File

@ -1,3 +1,4 @@
import { effect, stop } from '@vue/reactivity'
import { import {
queueJob, queueJob,
nextTick, nextTick,
@ -568,4 +569,27 @@ describe('scheduler', () => {
await nextTick() await nextTick()
expect(count).toBe(1) expect(count).toBe(1)
}) })
// #910
test('should not run stopped reactive effects', async () => {
const spy = jest.fn()
// simulate parent component that toggles child
const job1 = () => {
stop(job2)
}
job1.id = 0 // need the id to ensure job1 is sorted before job2
// simulate child that's triggered by the same reactive change that
// triggers its toggle
const job2 = effect(() => spy())
expect(spy).toHaveBeenCalledTimes(1)
queueJob(job1)
queueJob(job2)
await nextTick()
// should not be called again
expect(spy).toHaveBeenCalledTimes(1)
})
}) })

View File

@ -3,27 +3,14 @@ import { isArray } from '@vue/shared'
import { ComponentPublicInstance } from './componentPublicInstance' import { ComponentPublicInstance } from './componentPublicInstance'
import { ComponentInternalInstance, getComponentName } from './component' import { ComponentInternalInstance, getComponentName } from './component'
import { warn } from './warning' import { warn } from './warning'
import { ReactiveEffect } from '@vue/reactivity'
export interface SchedulerJob { export interface SchedulerJob extends Function, Partial<ReactiveEffect> {
(): void
/** /**
* unique job id, only present on raw effects, e.g. component render effect * Attached by renderer.ts when setting up a component's render effect
* Used to obtain component information when reporting max recursive updates.
* dev only.
*/ */
id?: number
/**
* Indicates whether the job is allowed to recursively trigger itself.
* By default, a job cannot trigger itself because some built-in method calls,
* e.g. Array.prototype.push actually performs reads as well (#1740) which
* can lead to confusing infinite loops.
* The allowed cases are component update functions and watch callbacks.
* Component update functions may update child component props, which in turn
* trigger flush: "pre" watch callbacks that mutates state that the parent
* relies on (#1801). Watch callbacks doesn't track its dependencies so if it
* triggers itself again, it's likely intentional and it is the user's
* responsibility to perform recursive state mutation that eventually
* stabilizes (#1727).
*/
allowRecurse?: boolean
ownerInstance?: ComponentInternalInstance ownerInstance?: ComponentInternalInstance
} }
@ -243,7 +230,7 @@ function flushJobs(seen?: CountMap) {
try { try {
for (flushIndex = 0; flushIndex < queue.length; flushIndex++) { for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
const job = queue[flushIndex] const job = queue[flushIndex]
if (job) { if (job && job.active !== false) {
if (__DEV__ && checkRecursiveUpdates(seen!, job)) { if (__DEV__ && checkRecursiveUpdates(seen!, job)) {
continue continue
} }