javascriptvue.jsvuejs3vue-reactivity

Tracking down the root cause of unexpected reactivity in Vue 3 components


I am currently working on a fairly simple Vue-3 app, but experiencing an issue I'm not sure how to find the root cause of. In my app I have a view which is being rendered via the router. This view has some simple state (an object ref for storing the values of some text fields, and a boolean ref for toggling a modal on and off), and also makes use of a single state object from a pinia store.

The very first time I load the view I can edit the text fields which are bound to the object ref with ease, but the very first time I attempt to open the modal by clicking a button two things happen: The text fields are cleared, and the modal fails to open. After the button has been clicked the very first time, everything works the way I expect, and I can open and close the modal without disrupting the state of the view.

I have attempted adding watchers to all of the internal state refs so I can see if the values are changing, but I am not seeing those watchers triggered when the text fields are cleared after the first button click, and I'm at a loss for figuring out what might be causing this reactivity to occur.

EDIT: After a bit of digging I can see that the very first time I click the button to show the modal onMounted() fires on the view itself. Could this somehow be related to the modal being teleported into the body tag and causing the whole view to be remounted? /EDIT

For context, here is the code of the view (TemplateEditorView.vue):

<script setup>

import TemplateSelectorComponent from '../components/TemplateSelectorComponent.vue'
import useTemplate from '../composables/useTemplate'
import { computed, ref, watch } from 'vue'
import AddSectionModal from '../components/AddSectionModal.vue'
import { useTemplateStore } from '../stores/template'
import { storeToRefs } from 'pinia'

const templateStore = useTemplateStore()
const { templates } = storeToRefs(templateStore)
const { selectedTemplate, selectedTemplateKey, updateSelectedTemplateKey } = useTemplate()

const newTemplate = ref({
    id: '',
    name: '',
    templateText: '',
    sections: []
})
const showModal = ref(false)

const handleModalSubmit = function (section) {
    newTemplate.value.sections.push(section)
    showModal.value = false
}

const handleSave = function () {
    if (saveEnabled.value) {
        templates.value[newTemplate.value.id] = newTemplate.value
        clearForm()
    }
}

function clearForm() {
    newTemplate.value = {
        id: '',
        name: '',
        templateText: '',
        sections: []
    }
}

const handleDelete = function () {
    if (deleteEnabled.value) {
        delete templates.value[selectedTemplateKey.value]
        clearForm()
    }
}

const deleteEnabled = computed(() => {
    return Object.hasOwn(templates.value, selectedTemplateKey.value)
})

const saveEnabled = computed(() => {
    return newTemplate.value.id && newTemplate.value.name && newTemplate.value.templateText
})

watch(selectedTemplate, async () => {
    newTemplate.value = selectedTemplate.value
})
</script>

<template>
    <div id="template-editor-view-content" class="row p-1 pt-4">
        <div class="col">
            <div class="row">
                <div class="col"><h1>Template Editor</h1></div>
            </div>
            <div class="row">
                <div id="options-column" class="col-4 primary-bordered vh-85">
                    <div class="row pt-3">
                        <div class="col"><h2 class="text-center">Template Options</h2></div>
                    </div>
                    <TemplateSelectorComponent @selected-template-changed="updateSelectedTemplateKey" />
                    <div class="row">
                        <div class="col">
                            <h3 class="text-center">Global Variables</h3>
                        </div>
                    </div>
                    <div class="row">
                        <div class="col">
                            <div id="globalVarsAccordion" class="accordion">
                                <div class="accordion-item">
                                    <h2 id="companyNameAccordionHeader" class="accordion-header">
                                        <button
                                            class="accordion-button"
                                            type="button"
                                            data-bs-toggle="collapse"
                                            data-bs-target="#companyNameAccordionBody"
                                            aria-expanded="false"
                                            aria-controls="companyNameAccordionBody"
                                        >
                                            Company Name
                                        </button>
                                    </h2>
                                    <div
                                        id="companyNameAccordionBody"
                                        class="accordion-collapse collapse"
                                        aria-labelledby="companyNameAccordionHeader"
                                        data-bs-parent="#globalVarsAccordion"
                                    >
                                        <div class="accordion-body">
                                            <ul class="list-group list-group-horizontal">
                                                <li class="list-group-item">isSelected</li>
                                                <li v-pre class="list-group-item">{{ companyName.isSelected }}</li>
                                            </ul>
                                            <ul class="pt-2 list-group list-group-horizontal">
                                                <li class="list-group-item">value</li>
                                                <li v-pre class="list-group-item">{{ companyName.value }}</li>
                                            </ul>
                                        </div>
                                    </div>
                                </div>
                                <div class="accordion-item">
                                    <h2 id="jobTitleAccordionHeader" class="accordion-header">
                                        <button
                                            class="accordion-button"
                                            type="button"
                                            data-bs-toggle="collapse"
                                            data-bs-target="#jobTitleAccordionBody"
                                            aria-expanded="false"
                                            aria-controls="jobTitleAccordionBody"
                                        >
                                            Job Title
                                        </button>
                                    </h2>
                                    <div
                                        id="jobTitleAccordionBody"
                                        class="accordion-collapse collapse"
                                        aria-labelledby="jobTitleAccordionHeader"
                                        data-bs-parent="#globalVarsAccordion"
                                    >
                                        <div class="accordion-body">
                                            <ul class="list-group list-group-horizontal">
                                                <li class="list-group-item">isSelected</li>
                                                <li v-pre class="list-group-item">{{ jobTitle.isSelected }}</li>
                                            </ul>
                                            <ul class="pt-2 list-group list-group-horizontal">
                                                <li class="list-group-item">value</li>
                                                <li v-pre class="list-group-item">{{ jobTitle.value }}</li>
                                            </ul>
                                        </div>
                                    </div>
                                </div>
                            </div>
                        </div>
                    </div>
                    <template v-if="newTemplate.sections">
                        <div class="row">
                            <div class="col">
                                <h3 class="text-center">Template Variables</h3>
                            </div>
                        </div>
                        <div class="row">
                            <div class="col">
                                <div id="templateVarsAccordion" class="accordion">
                                    <div
                                        v-for="section in newTemplate.sections"
                                        :key="section.key"
                                        class="accordion-item"
                                    >
                                        <h2 id="`${section.key}AccordionHeader`" class="accordion-header">
                                            <button
                                                class="accordion-button"
                                                type="button"
                                                data-bs-toggle="collapse"
                                                data-bs-target="#`${section.key}AccordionBody`"
                                                aria-expanded="false"
                                                aria-controls="`${section.key}AccordionBody`"
                                            >
                                                {{ section.label }}
                                            </button>
                                        </h2>
                                        <div
                                            id="`${section.key}AccordionBody`"
                                            class="accordion-collapse collapse"
                                            aria-labelledby="`${section.key}AccordionHeader`"
                                            data-bs-parent="#templateVarsAccordion"
                                        >
                                            <div class="accordion-body">
                                                <ul class="list-group list-group-horizontal">
                                                    <li class="list-group-item">isSelected</li>
                                                    <li class="list-group-item">
                                                        <span v-pre>{{</span> {{ section.key }}.isSelected <span v-pre>}}</span>
                                                    </li>
                                                </ul>
                                                <ul class="pt-2 list-group list-group-horizontal">
                                                    <li class="list-group-item">value</li>
                                                    <li class="list-group-item">
                                                        <span v-pre>{{</span> {{ section.key }}.value <span v-pre>}}</span>
                                                    </li>
                                                </ul>
                                            </div>
                                        </div>
                                    </div>
                                </div>
                            </div>
                        </div>
                    </template>
                </div>
                <div id="working-area" class="col vh-85">
                    <form class="row gx-3 align-items-center pb-3 pt-2">
                        <div class="col-auto">
                            <label for="templateKeyInput" class="col-form-label-lg">Template ID</label>
                        </div>
                        <div class="col">
                            <input
                                id="templateKeyInput"
                                v-model="newTemplate.id"
                                type="text"
                                class="form-control form-control-lg"
                            />
                        </div>
                        <div class="col-auto">
                            <label for="templateNameInput" class="col-form-label-lg">Template Name</label>
                        </div>
                        <div class="col">
                            <input
                                id="templateNameInput"
                                v-model="newTemplate.name"
                                type="text"
                                class="form-control form-control-lg"
                            />
                        </div>
                    </form>
                    <form class="row text-center justify-content-center">
                        <div class="col-2">
                            <button class="btn btn-secondary" @click="showModal = true">Add Section</button>
                        </div>
                        <div class="col-2">
                            <button class="btn btn-success" :disabled="!saveEnabled" @click="handleSave">
                                Save Template
                            </button>
                        </div>
                        <div class="col-2">
                            <button class="btn btn-danger" :disabled="!deleteEnabled" @click="handleDelete">
                                Delete Template
                            </button>
                        </div>
                    </form>
                    <div class="row pt-3">
                        <div class="col">
                            <textarea
                                id="templateTextArea"
                                v-model="newTemplate.templateText"
                                class="form-control"
                            ></textarea>
                        </div>
                    </div>
                </div>
            </div>
        </div>
        <AddSectionModal :show="showModal" @close="showModal = false" @submit="handleModalSubmit" />
    </div>
</template>

<style scoped>
// Some css
</style>

The modal which is being created is defined as follows (AddSectionModal.vue):

<script setup>
import GenericModal from './GenericModal.vue'
import { computed, ref } from 'vue'

defineProps({
    show: Boolean
})
const emit = defineEmits(['close', 'submit'])

const section = ref({
    key: '',
    label: '',
    text: '',
    isSelected: false
})

function clearState() {
    section.value = {
        key: '',
        label: '',
        text: '',
        isSelected: false
    }
}

const submitEnabled = computed(() => {
    return section.value.key && section.value.text && section.value.label
})

const handleClose = function () {
    clearState()
    emit('close')
}

const handleSubmit = function () {
    if (submitEnabled.value) {
        const value = section.value
        clearState()
        emit('submit', value)
    }
}
</script>

<template>
    <Teleport to="body">
        <GenericModal :show="show">
            <template #header>
                <h5 class="mx-auto">Add New Section</h5>
            </template>

            <template #body>
                <div class="container">
                    <div class="row gx-3 align-items-center">
                        <div class="col-3">
                            <label for="sectionKey" class="col-form-label-lg">Section Key</label>
                        </div>
                        <div class="col">
                            <input
                                v-model="section.key"
                                name="sectionKey"
                                type="text"
                                class="form-control form-control-lg"
                            />
                        </div>
                    </div>
                    <div class="row pt-2 gx-3 align-items-center">
                        <div class="col-3">
                            <label for="sectionLabel" class="col-form-label-lg">Section Label</label>
                        </div>
                        <div class="col">
                            <input
                                v-model="section.label"
                                name="sectionLabel"
                                type="text"
                                class="form-control form-control-lg"
                            />
                        </div>
                    </div>
                    <div class="row pt-2 gx-3 align-items-center">
                        <div class="col-3">
                            <label for="sectionText" class="col-form-label-lg">Section Text</label>
                        </div>
                        <div class="col">
                            <input
                                v-model="section.text"
                                name="sectionText"
                                type="text"
                                class="form-control form-control-lg"
                            />
                        </div>
                    </div>
                </div>
            </template>

            <template #footer>
                <div class="row gx-1 float-end">
                    <div class="col">
                        <button class="btn btn-danger" @click="handleClose">Cancel</button>
                    </div>
                    <div class="col">
                        <button class="btn btn-success" :disabled="!submitEnabled" @click="handleSubmit">
                            Submit
                        </button>
                    </div>
                </div>
            </template>
        </GenericModal>
    </Teleport>
</template>

And the GenericModal implementation is as follows (GenericModal.vue):

<template>
    <Transition name="modal">
        <div v-if="show" class="modal-mask">
            <div class="modal-container">
                <div class="modal-header">
                    <slot name="header">default header</slot>
                </div>

                <div class="modal-body">
                    <slot name="body">default body</slot>
                </div>

                <div class="modal-footer">
                    <slot name="footer">
                        default footer
                        <button class="modal-default-button" @click="$emit('close')">OK</button>
                    </slot>
                </div>
            </div>
        </div>
    </Transition>
</template>

<style>
// Some CSS
</style>

What strategies could I use to track down the source of the unexpected reactivity?


Solution

  • Since this is Electron project, it may take additional steps to enable Vue devtools, but they are not necessary, as this is general task that allows to modify source code for the purpose of debugging.

    The problem here is that some state modifications are performed directly in the template. They should be moved to functions to make it easier to set up a breakpoint.

    A simple way to debug reactivity in your own code is to add a watcher with debugging hooks, e.g:

    watchEffect(
      () => {
        showModal.value;
        debugger;
      },
      {
        flush: 'post',
        onTrack(e) {
          //  debugger
        },
        onTrigger(e) {
          debugger
        }
      }
    )
    

    If a modal was switched by unexpected changes in showModal state, this could be tracked, and the exact place where this happens could be seen in call stack, this includes inline event handlers in the template. This won't be helpful in this case because it's evident that showModal is changed in a limited way and cannot be accidentally triggered twice.

    The reason can be seen in the console (with "preserve log" option enabled and filters disabled):

    console

    The page is initially loaded to http://localhost:5173/ plus hash part for routing. Then a redirect to http://localhost:5173/? happens when a button is clicked, and the page reloads. This is related to HTML that was used in the template, not the implementation of modal components.

    <form class="row text-center justify-content-center">
        <div class="col-2">
            <button class="btn btn-secondary" @click="showModal = true">
                Add Section
            </button>
    

    button with no type submits a form by default, and a form without action, method and fields is submitted to ?, so http://localhost:5173/? redirect occurs. It stays on the same page on the subsequent submits, hence the difference between the first and the rest of modal open actions.

    The solution is to not use <form> at all if you don't handle submit event, it's of no use. If you use it for semantic reasons only, use <form @submit.prevent> instead.