Skip to content

Commit c8e2d4a

Browse files
authored
fix(reactivity): prevent orphan effect when created in a stopped scope (#14778)
close #14777
1 parent 7f46fd4 commit c8e2d4a

3 files changed

Lines changed: 243 additions & 8 deletions

File tree

packages/reactivity/src/effect.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,8 +114,19 @@ export class ReactiveEffect<T = any>
114114
onTrigger?: (event: DebuggerEvent) => void
115115

116116
constructor(public fn: () => T) {
117-
if (activeEffectScope && activeEffectScope.active) {
118-
activeEffectScope.effects.push(this)
117+
if (activeEffectScope) {
118+
if (activeEffectScope.active) {
119+
activeEffectScope.effects.push(this)
120+
} else {
121+
// The active scope has already been stopped. This happens when a
122+
// component's setup is resumed after a top-level `await` (via the
123+
// compiler-emitted `__restore()` from `withAsyncContext`) but the
124+
// component was unmounted while pending under <Suspense>. Without
125+
// this guard the effect would become an orphan: not held by any
126+
// scope (so it cannot be stopped via the scope chain) yet still
127+
// able to subscribe to reactive deps and fire forever.
128+
this.flags &= ~EffectFlags.ACTIVE
129+
}
119130
}
120131
}
121132

packages/reactivity/src/effectScope.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export class EffectScope {
2222
cleanups: (() => void)[] = []
2323

2424
private _isPaused = false
25+
private _warnOnRun = true
2526

2627
/**
2728
* only assigned by undetached scope
@@ -44,12 +45,19 @@ export class EffectScope {
4445
// TODO isolatedDeclarations ReactiveFlags.SKIP
4546

4647
constructor(public detached = false) {
47-
this.parent = activeEffectScope
4848
if (!detached && activeEffectScope) {
49-
this.index =
50-
(activeEffectScope.scopes || (activeEffectScope.scopes = [])).push(
51-
this,
52-
) - 1
49+
if (activeEffectScope.active) {
50+
this.parent = activeEffectScope
51+
this.index =
52+
(activeEffectScope.scopes || (activeEffectScope.scopes = [])).push(
53+
this,
54+
) - 1
55+
} else {
56+
// The parent scope has already stopped, so this child must not become
57+
// a detached live scope.
58+
this._active = false
59+
this._warnOnRun = false
60+
}
5361
}
5462
}
5563

@@ -101,7 +109,7 @@ export class EffectScope {
101109
} finally {
102110
activeEffectScope = currentEffectScope
103111
}
104-
} else if (__DEV__) {
112+
} else if (__DEV__ && this._warnOnRun) {
105113
warn(`cannot run an inactive effect scope.`)
106114
}
107115
}

packages/runtime-core/__tests__/components/Suspense.spec.ts

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
createBlock,
1313
createCommentVNode,
1414
createElementBlock,
15+
getCurrentInstance,
1516
h,
1617
nextTick,
1718
nodeOps,
@@ -29,13 +30,15 @@ import {
2930
shallowRef,
3031
watch,
3132
watchEffect,
33+
withAsyncContext,
3234
withDirectives,
3335
} from '@vue/runtime-test'
3436
import {
3537
computed,
3638
createApp,
3739
defineAsyncComponent as defineAsyncComp,
3840
defineComponent,
41+
effectScope,
3942
inject,
4043
provide,
4144
} from 'vue'
@@ -3009,4 +3012,217 @@ describe('Suspense', () => {
30093012
expect(unmounted).toHaveBeenCalledTimes(1)
30103013
})
30113014
})
3015+
3016+
describe('async setup with top-level await', () => {
3017+
test('pending branch replaced before async setup resolves', async () => {
3018+
const updateSpy = vi.fn()
3019+
const stateRef = ref(0)
3020+
let resolvePending!: (v?: unknown) => void
3021+
3022+
const SlowComp = defineComponent({
3023+
async setup() {
3024+
let __temp: any, __restore: any
3025+
;[__temp, __restore] = withAsyncContext(
3026+
() =>
3027+
new Promise(r => {
3028+
resolvePending = r
3029+
}),
3030+
)
3031+
__temp = await __temp
3032+
__restore()
3033+
3034+
watch(stateRef, updateSpy)
3035+
3036+
return () => h('div', 'slow')
3037+
},
3038+
})
3039+
3040+
const FillerComp = defineComponent({
3041+
setup: () => () => h('div', 'filler'),
3042+
})
3043+
3044+
const view = shallowRef<any>(SlowComp)
3045+
const Comp = defineComponent({
3046+
setup: () => () =>
3047+
h(Suspense, null, {
3048+
default: h(view.value),
3049+
fallback: h('div', 'fallback'),
3050+
}),
3051+
})
3052+
3053+
const root = nodeOps.createElement('div')
3054+
render(h(Comp), root)
3055+
expect(serializeInner(root)).toBe(`<div>fallback</div>`)
3056+
3057+
view.value = FillerComp
3058+
await nextTick()
3059+
expect(serializeInner(root)).toBe(`<div>filler</div>`)
3060+
3061+
// wait a macro task tick for all micro ticks to resolve
3062+
resolvePending(undefined)
3063+
await new Promise(r => setTimeout(r))
3064+
3065+
stateRef.value++
3066+
await nextTick()
3067+
expect(updateSpy).not.toHaveBeenCalled()
3068+
})
3069+
3070+
test('boundary unmounted before async setup resolves', async () => {
3071+
const updateSpy = vi.fn()
3072+
const stateRef = ref(0)
3073+
let resolvePending!: (v?: unknown) => void
3074+
3075+
const SlowComp = defineComponent({
3076+
async setup() {
3077+
let __temp: any, __restore: any
3078+
;[__temp, __restore] = withAsyncContext(
3079+
() =>
3080+
new Promise(r => {
3081+
resolvePending = r
3082+
}),
3083+
)
3084+
__temp = await __temp
3085+
__restore()
3086+
3087+
watch(stateRef, updateSpy)
3088+
3089+
return () => h('div', 'slow')
3090+
},
3091+
})
3092+
3093+
const root = nodeOps.createElement('div')
3094+
render(
3095+
h(() =>
3096+
h(Suspense, null, {
3097+
default: h(SlowComp),
3098+
fallback: h('div', 'fallback'),
3099+
}),
3100+
),
3101+
root,
3102+
)
3103+
expect(serializeInner(root)).toBe(`<div>fallback</div>`)
3104+
3105+
render(null, root)
3106+
3107+
resolvePending(undefined)
3108+
await new Promise(r => setTimeout(r))
3109+
3110+
stateRef.value++
3111+
await nextTick()
3112+
expect(updateSpy).not.toHaveBeenCalled()
3113+
})
3114+
3115+
test('repeated branch replacement before async setup resolves', async () => {
3116+
const updateSpy = vi.fn()
3117+
const stateRef = ref(0)
3118+
const resolvers: Array<(v?: unknown) => void> = []
3119+
3120+
const SlowComp = defineComponent({
3121+
async setup() {
3122+
let __temp: any, __restore: any
3123+
;[__temp, __restore] = withAsyncContext(
3124+
() =>
3125+
new Promise(r => {
3126+
resolvers.push(r)
3127+
}),
3128+
)
3129+
__temp = await __temp
3130+
__restore()
3131+
3132+
const uid = getCurrentInstance()!.uid
3133+
watch(stateRef, () => {
3134+
updateSpy(uid)
3135+
})
3136+
3137+
return () => h('div', 'slow')
3138+
},
3139+
})
3140+
3141+
const FillerComp = defineComponent({
3142+
setup: () => () => h('div', 'filler'),
3143+
})
3144+
3145+
const view = shallowRef<any>(FillerComp)
3146+
const Comp = defineComponent({
3147+
setup: () => () =>
3148+
h(Suspense, null, {
3149+
default: h(view.value),
3150+
fallback: h('div', 'fallback'),
3151+
}),
3152+
})
3153+
3154+
const root = nodeOps.createElement('div')
3155+
render(h(Comp), root)
3156+
expect(serializeInner(root)).toBe(`<div>filler</div>`)
3157+
3158+
for (let i = 0; i < 3; i++) {
3159+
view.value = SlowComp
3160+
await nextTick()
3161+
view.value = FillerComp
3162+
await nextTick()
3163+
}
3164+
3165+
expect(resolvers.length).toBe(3)
3166+
resolvers.forEach(r => r(undefined))
3167+
await new Promise(r => setTimeout(r))
3168+
3169+
stateRef.value++
3170+
await nextTick()
3171+
expect(updateSpy).not.toHaveBeenCalled()
3172+
})
3173+
3174+
test('nested scope created after pending branch is abandoned', async () => {
3175+
const updateSpy = vi.fn()
3176+
const stateRef = ref(0)
3177+
let resolvePending!: (v?: unknown) => void
3178+
3179+
const SlowComp = defineComponent({
3180+
async setup() {
3181+
let __temp: any, __restore: any
3182+
;[__temp, __restore] = withAsyncContext(
3183+
() =>
3184+
new Promise(r => {
3185+
resolvePending = r
3186+
}),
3187+
)
3188+
__temp = await __temp
3189+
__restore()
3190+
3191+
effectScope().run(() => {
3192+
watch(stateRef, updateSpy)
3193+
})
3194+
3195+
return () => h('div', 'slow')
3196+
},
3197+
})
3198+
3199+
const FillerComp = defineComponent({
3200+
setup: () => () => h('div', 'filler'),
3201+
})
3202+
3203+
const view = shallowRef<any>(SlowComp)
3204+
const Comp = defineComponent({
3205+
setup: () => () =>
3206+
h(Suspense, null, {
3207+
default: h(view.value),
3208+
fallback: h('div', 'fallback'),
3209+
}),
3210+
})
3211+
3212+
const root = nodeOps.createElement('div')
3213+
render(h(Comp), root)
3214+
expect(serializeInner(root)).toBe(`<div>fallback</div>`)
3215+
3216+
view.value = FillerComp
3217+
await nextTick()
3218+
expect(serializeInner(root)).toBe(`<div>filler</div>`)
3219+
3220+
resolvePending(undefined)
3221+
await new Promise(r => setTimeout(r))
3222+
3223+
stateRef.value++
3224+
await nextTick()
3225+
expect(updateSpy).not.toHaveBeenCalled()
3226+
})
3227+
})
30123228
})

0 commit comments

Comments
 (0)