Skip to content

Commit

Permalink
fix(reactivity): ensure computed always expose value
Browse files Browse the repository at this point in the history
fix #3099

Also changes the original fix for #910 by moving the fix from
reactivity to the scheduler
  • Loading branch information
yyx990803 committed May 28, 2021
1 parent 32e2133 commit 03a7a73
Show file tree
Hide file tree
Showing 5 changed files with 52 additions and 42 deletions.
6 changes: 6 additions & 0 deletions packages/reactivity/__tests__/computed.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,4 +193,10 @@ describe('reactivity/computed', () => {
expect(isReadonly(z)).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)
})
})
22 changes: 0 additions & 22 deletions packages/reactivity/__tests__/effect.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -709,28 +709,6 @@ describe('reactivity/effect', () => {
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', () => {
const onStop = jest.fn()
const runner = effect(() => {}, {
Expand Down
17 changes: 16 additions & 1 deletion packages/reactivity/src/effect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,21 @@ export interface ReactiveEffectOptions {
onTrack?: (event: DebuggerEvent) => void
onTrigger?: (event: DebuggerEvent) => 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
}

Expand Down Expand Up @@ -84,7 +99,7 @@ function createReactiveEffect<T = any>(
): ReactiveEffect<T> {
const effect = function reactiveEffect(): unknown {
if (!effect.active) {
return options.scheduler ? undefined : fn()
return fn()
}
if (!effectStack.includes(effect)) {
cleanup(effect)
Expand Down
24 changes: 24 additions & 0 deletions packages/runtime-core/__tests__/scheduler.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { effect, stop } from '@vue/reactivity'
import {
queueJob,
nextTick,
Expand Down Expand Up @@ -568,4 +569,27 @@ describe('scheduler', () => {
await nextTick()
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)
})
})
25 changes: 6 additions & 19 deletions packages/runtime-core/src/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,14 @@ import { isArray } from '@vue/shared'
import { ComponentPublicInstance } from './componentPublicInstance'
import { ComponentInternalInstance, getComponentName } from './component'
import { warn } from './warning'
import { ReactiveEffect } from '@vue/reactivity'

export interface SchedulerJob {
(): void
export interface SchedulerJob extends Function, Partial<ReactiveEffect> {
/**
* 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
}

Expand Down Expand Up @@ -243,7 +230,7 @@ function flushJobs(seen?: CountMap) {
try {
for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
const job = queue[flushIndex]
if (job) {
if (job && job.active !== false) {
if (__DEV__ && checkRecursiveUpdates(seen!, job)) {
continue
}
Expand Down

0 comments on commit 03a7a73

Please sign in to comment.