Peculiar rendering bug on iOS with v-bind, v-if, and nested single-file components


#1

Hello!

I’m encountering a bug on iOS browsers (both Safari and Chrome) whose origin is very mysterious to me.

The setup is like so:
I have a component composed of two other components: a login form inside of a modal, conditionally displayed depending on a vuex property.

The project is using Webpack and single-file components, compiled using vue-loader.

There are 3 files involved in this bug:

  1. LoginModal.vue which is just a wrapper around
  2. Overlay.vue, a container component being used to wrap
  3. LoginForm.vue, a login form

The problem I’m encountering is that when I type into the text inputs, after I type a second character, the form disappears while the modal remains visible. The form will reappear on a touchmove event.

EDIT - ok, so after the refactor of placing the form code directly in the component, it seems the improvement is marginal or nonexistent and the improvements i was seeing initially were probably caused by other factors. It’s also become clear that this bug only appears on typing the second character. If I get the form to reappear by closing and reopening the modal, the typing proceeds normally and the form remains on the screen. If I get it to reappear by touchmove, the problem persists. Weeeird.

The problem first started occurring when I refactored the login form, separating it into its own file. I was already importing the overlay from its own .vue file since it is used in many components across the site, but I was not importing the login form itself. It was written inline. It’s when the login form was being imported into a third component that the problem arose.

If I remove the v-if in the overlay component or set the property it depends on to true, the problem goes away. If I write the form directly into LoginModal.vue, the problem goes away. The first fix is obviously not applicable. The second would work, but would duplicate code and I’d like to avoid that if I can.

The problem appears on iOS only and only using the file structure where the login form and the overlay are both imported into a third file that composes them. So, I recognize that this is a very…niche bug, but interesting nevertheless.

I suspect 2 things:

  1. The problem may be originating from vue-loader somehow, since it goes away if I reduce the 3 component files to 2.
  2. The problem is likely performance related.

Let me know if anybody has any insight. Sorry this post has become so long, and thanks in advance!

Here’s some code:

// LoginModal.vue

<template>
  <overlay v-if="loginFormIsActive" content-class="login__content">
    <login-form></login-form>
  </overlay>
</template>

<script>
  import { mapGetters } from 'vuex';
  import overlay from '../container/Overlay.vue';
  import loginForm from './LoginForm.vue';

  export default {
    computed: { ...mapGetters(['loginFormIsActive']),
    
    components: {
      'overlay': overlay,
      'login-form' : loginForm
    }
  }
</script>


// Overlay.vue
<template>
	<transition name="fade">
	  <div class="overlay">
			<div class="overlay__wrapper">
				<div class="overlay__header">
					<a class="overlay__collapse" v-on:click.prevent="closeModal"></a>
				</div>
				<div class="overlay__content" :class="contentClass">
					<slot></slot>
				</div>
			</div>
		</div>
	</transition>
</template>

<script>
	import { mapMutations } from 'vuex';
	export default {
		props: ['contentClass'],
		methods: {
			...mapMutations(['closeModal'])
		}
	}
</script>

<style lang="scss">
	.fade-enter-active, .fade-leave-active {
	  transition: opacity .5s
	}
	.fade-enter, .fade-leave-to /* .fade-leave-active in <2.1.8 */ {
	  opacity: 0
	}
</style>


// LoginForm.vue

<template>
    <form class="form">

        <p class="form__message--error">{{loginMessage}}</p>

        <input class="form__text-input" type="text" v-model="credentials.username" placeholder="username or email">
        <input class="form__text-input" type="password" v-model="credentials.password" placeholder="password">

        <p class="form__fine-print"><a :href="recoverLink">Forgot your password?</a></p>

        <submit-button class="form__submit" button-text="Submit" :callback="submit" associated-action="userLogin"></submit-button>
    </form>
</template>
<script>
    import { mapState, mapMutations, mapActions } from 'vuex';
    import submitButton from './SubmitButton.vue';
    import MODALS from '../../constants/Modals.js';
    import NAVIGATION from '../../constants/Navigation.js';

    export default {
        data(){
            return {
                credentials: {
                    username: '',
                    password: '',
                },
                privacyLink: `${NAVIGATION.SITE_PATH}/privacy`,
                termsLink: `${NAVIGATION.SITE_PATH}/terms`
            }
        },
        computed: {
            ...mapState({
                userIsLoggedIn: state => state.user.userIsLoggedIn,
                loginMessage:   state => state.user.loginMessage,
                activeModal:    state => state.activeModal,
                prompt:         state => state.login.prompt
            }),

            hasError: function(){
                return this.validationErrors.any();
            },
            recoverLink: () => NAVIGATION.WEBROOT + '/recover'
        },
        methods: {
            ...mapMutations(['openModal']),
            ...mapMutations('login', ['resetLoginPrompt']),
            ...mapActions('user', [
                'userLogin',
                'userLogout'
            ]),
            submit(){
                const credentials = {
                    _username: this.credentials.username,
                    _password: this.credentials.password
                };
                this.userLogin(credentials)
                .then();
            },
            logoutUser: function(){
                this.userLogout();
            },
            openRegisterForm(){
                this.openModal(MODALS.REGISTER_FORM)
            }
        },
        watch: {
            userIsLoggedIn: function(theyAre){
                if ( theyAre  && this.activeModal === MODALS.LOGIN_FORM ) {
                    window.setTimeout(()=>{this.closeModal()}, 200);
                }
            }
        },
        components:{
            'submit-button': submitButton
        }
    }
</script>

#2

Sounds quite strange… it’s hard to know without seeing the project, however there’s a couple things you could clean up which may help.

You are missing the closing </overlay> tag

<template>
  <overlay v-if="loginFormIsActive" content-class="login__content">
    <login-form></login-form>
</template>

You have multiple mapMutations when you could just use one (I don’t think this should matter, but it’s worth mentioning)

...mapMutations(['openModal']),
...mapMutations('login', ['resetLoginPrompt']),

Further question: Is it actually removing the form from the DOM or is it visually hiding it?


#3

Oops. The unclosed <overlay> tag was a transcription error, not in the original code.

The multiple ...mapMutations are just to keep the namespacing clear.

As to your question about removing the form from the virtual DOM or hiding it, I’m not entirely sure, but it’s a good question – I’ll see if I can check on that when I’m back at my desk


#4

UPDATE

The fix was to remove -webkit-overflow-scrolling: touch on the body element. It seems the bug was not related directly to Vue.js. I suspect it had to do with hardware acceleration creating a race condition between repaints and JS execution, but this is still just a hunch. I am trying to reproduce the bug in isolation.