Skip to content

Commit

Permalink
fix(reactivity): avoid infinite loop when render access a side effect…
Browse files Browse the repository at this point in the history
… computed (#11135)

close #11121
  • Loading branch information
Doctor-wu authored Jun 14, 2024
1 parent a23e99b commit 8296e19
Show file tree
Hide file tree
Showing 4 changed files with 129 additions and 12 deletions.
101 changes: 95 additions & 6 deletions packages/reactivity/__tests__/computed.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,78 @@ describe('reactivity/computed', () => {
expect(fnSpy).toBeCalledTimes(2)
})

it('should mark dirty as MaybeDirty_ComputedSideEffect_Origin', () => {
const v = ref(1)
const c = computed(() => {
v.value += 1
return v.value
})

c.value
expect(c.effect._dirtyLevel).toBe(
DirtyLevels.MaybeDirty_ComputedSideEffect_Origin,
)
expect(COMPUTED_SIDE_EFFECT_WARN).toHaveBeenWarned()
})

it('should not infinite re-run effect when effect access original side effect computed', async () => {
const spy = vi.fn()
const v = ref(0)
const c = computed(() => {
v.value += 1
return v.value
})
const Comp = {
setup: () => {
return () => {
spy()
return v.value + c.value
}
},
}
const root = nodeOps.createElement('div')

render(h(Comp), root)
expect(spy).toBeCalledTimes(1)
await nextTick()
expect(c.effect._dirtyLevel).toBe(
DirtyLevels.MaybeDirty_ComputedSideEffect_Origin,
)
expect(serializeInner(root)).toBe('2')
expect(COMPUTED_SIDE_EFFECT_WARN).toHaveBeenWarned()
})

it('should not infinite re-run effect when effect access chained side effect computed', async () => {
const spy = vi.fn()
const v = ref(0)
const c1 = computed(() => {
v.value += 1
return v.value
})
const c2 = computed(() => v.value + c1.value)
const Comp = {
setup: () => {
return () => {
spy()
return v.value + c1.value + c2.value
}
},
}
const root = nodeOps.createElement('div')

render(h(Comp), root)
expect(spy).toBeCalledTimes(1)
await nextTick()
expect(c1.effect._dirtyLevel).toBe(
DirtyLevels.MaybeDirty_ComputedSideEffect_Origin,
)
expect(c2.effect._dirtyLevel).toBe(
DirtyLevels.MaybeDirty_ComputedSideEffect,
)
expect(serializeInner(root)).toBe('4')
expect(COMPUTED_SIDE_EFFECT_WARN).toHaveBeenWarned()
})

it('should chained recurse effects clear dirty after trigger', () => {
const v = ref(1)
const c1 = computed(() => v.value)
Expand All @@ -482,7 +554,9 @@ describe('reactivity/computed', () => {

c3.value

expect(c1.effect._dirtyLevel).toBe(DirtyLevels.Dirty)
expect(c1.effect._dirtyLevel).toBe(
DirtyLevels.MaybeDirty_ComputedSideEffect_Origin,
)
expect(c2.effect._dirtyLevel).toBe(
DirtyLevels.MaybeDirty_ComputedSideEffect,
)
Expand All @@ -502,7 +576,9 @@ describe('reactivity/computed', () => {
})
const c2 = computed(() => v.value + c1.value)
expect(c2.value).toBe('0foo')
expect(c2.effect._dirtyLevel).toBe(DirtyLevels.Dirty)
expect(c2.effect._dirtyLevel).toBe(
DirtyLevels.MaybeDirty_ComputedSideEffect,
)
expect(c2.value).toBe('1foo')
expect(COMPUTED_SIDE_EFFECT_WARN).toHaveBeenWarned()
})
Expand All @@ -523,8 +599,12 @@ describe('reactivity/computed', () => {
c2.value
})
expect(fnSpy).toBeCalledTimes(1)
expect(c1.effect._dirtyLevel).toBe(DirtyLevels.Dirty)
expect(c2.effect._dirtyLevel).toBe(DirtyLevels.Dirty)
expect(c1.effect._dirtyLevel).toBe(
DirtyLevels.MaybeDirty_ComputedSideEffect_Origin,
)
expect(c2.effect._dirtyLevel).toBe(
DirtyLevels.MaybeDirty_ComputedSideEffect,
)
v.value = 2
expect(fnSpy).toBeCalledTimes(2)
expect(COMPUTED_SIDE_EFFECT_WARN).toHaveBeenWarned()
Expand Down Expand Up @@ -557,7 +637,9 @@ describe('reactivity/computed', () => {
expect(c3.effect._dirtyLevel).toBe(DirtyLevels.MaybeDirty)

c3.value
expect(c1.effect._dirtyLevel).toBe(DirtyLevels.Dirty)
expect(c1.effect._dirtyLevel).toBe(
DirtyLevels.MaybeDirty_ComputedSideEffect_Origin,
)
expect(c2.effect._dirtyLevel).toBe(
DirtyLevels.MaybeDirty_ComputedSideEffect,
)
Expand Down Expand Up @@ -611,11 +693,18 @@ describe('reactivity/computed', () => {

render(h(Comp), root)
await nextTick()
expect(c.effect._dirtyLevel).toBe(
DirtyLevels.MaybeDirty_ComputedSideEffect_Origin,
)
expect(serializeInner(root)).toBe('Hello World')

v.value += ' World'
expect(c.effect._dirtyLevel).toBe(DirtyLevels.Dirty)
await nextTick()
expect(serializeInner(root)).toBe('Hello World World World World')
expect(c.effect._dirtyLevel).toBe(
DirtyLevels.MaybeDirty_ComputedSideEffect_Origin,
)
expect(serializeInner(root)).toBe('Hello World World World')
expect(COMPUTED_SIDE_EFFECT_WARN).toHaveBeenWarned()
})

Expand Down
5 changes: 4 additions & 1 deletion packages/reactivity/src/computed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,10 @@ export class ComputedRefImpl<T> {
triggerRefValue(self, DirtyLevels.Dirty)
}
trackRefValue(self)
if (self.effect._dirtyLevel >= DirtyLevels.MaybeDirty_ComputedSideEffect) {
if (
self.effect._dirtyLevel >=
DirtyLevels.MaybeDirty_ComputedSideEffect_Origin
) {
if (__DEV__ && (__TEST__ || this._warnRecursive)) {
warn(COMPUTED_SIDE_EFFECT_WARN, `\n\ngetter: `, this.getter)
}
Expand Down
11 changes: 6 additions & 5 deletions packages/reactivity/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,10 @@ export enum ReactiveFlags {
}

export enum DirtyLevels {
NotDirty = 0,
QueryingDirty = 1,
MaybeDirty_ComputedSideEffect = 2,
MaybeDirty = 3,
Dirty = 4,
NotDirty,
QueryingDirty,
MaybeDirty_ComputedSideEffect_Origin,
MaybeDirty_ComputedSideEffect,
MaybeDirty,
Dirty,
}
24 changes: 24 additions & 0 deletions packages/reactivity/src/effect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ export class ReactiveEffect<T = any> {
}

public get dirty() {
// treat original side effect computed as not dirty to avoid infinite loop
if (this._dirtyLevel === DirtyLevels.MaybeDirty_ComputedSideEffect_Origin)
return false
if (
this._dirtyLevel === DirtyLevels.MaybeDirty_ComputedSideEffect ||
this._dirtyLevel === DirtyLevels.MaybeDirty
Expand All @@ -85,6 +88,13 @@ export class ReactiveEffect<T = any> {
for (let i = 0; i < this._depsLength; i++) {
const dep = this.deps[i]
if (dep.computed) {
// treat chained side effect computed as dirty to force it re-run
// since we know the original side effect computed is dirty
if (
dep.computed.effect._dirtyLevel ===
DirtyLevels.MaybeDirty_ComputedSideEffect_Origin
)
return true
triggerComputed(dep.computed)
if (this._dirtyLevel >= DirtyLevels.Dirty) {
break
Expand Down Expand Up @@ -296,13 +306,27 @@ export function triggerEffects(
) {
pauseScheduling()
for (const effect of dep.keys()) {
if (!dep.computed && effect.computed) {
if (dep.get(effect) === effect._trackId && effect._runnings > 0) {
effect._dirtyLevel = DirtyLevels.MaybeDirty_ComputedSideEffect_Origin
continue
}
}
// 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
// always schedule if the computed is original side effect
// since we know it is actually dirty
if (
effect.computed &&
effect._dirtyLevel === DirtyLevels.MaybeDirty_ComputedSideEffect_Origin
) {
effect._shouldSchedule = true
}
effect._dirtyLevel = dirtyLevel
}
if (
Expand Down

0 comments on commit 8296e19

Please sign in to comment.