Subscribe on changes!

Mutation inside onBeforeUpdate causes sideEffect delayed for 1 tick, how can I watch for el change?

avatar
Apr 9th 2021

Version

3.0.11

Reproduction link

https://github.com/mannok/vue-rel-el-test.git

Steps to reproduce

  1. go to the repository
  2. clone the project
  3. npm run dev
  4. open the project home page and open console tab
  5. click add one 5a. watch console
  6. click add one 6a. watch console

What is expected?

what you can see in console: 5a. before update there are 5 div 6a. before update there are 6 div

What is actually happening?

what you can see in console: 5a. before update 6a. there are 5 div before update


seems that the side effects caused in onBeforeUpdate have been marked. However, these side effects have not been flushed after the update cycle. How can I watch for el change? Thanks!

avatar
Apr 10th 2021

watch() and watchEffect() effects are run before the DOM is mounted or updated so the template ref hasn't been updated when the watcher runs the effect.

see https://v3.vuejs.org/guide/composition-api-template-refs.html#watching-template-refs

    watch(
          () => vm.divs,
          (to) => {
            console.log(`there are ${to.length} div`);
          },{
            flush:'post'
          }
        );
avatar
Apr 10th 2021

Thanks @edison1105, but I think it is not only related to template ref. The case is that "mutations' sideEffect inside onBeforeUpdate delayed for 1 tick". No matter the case is ref or not, mutations in onBeforeUpdate delayed. Is this by design?

Please take the following code to test.

<template>
    <label>{{ counter }}</label>
    <button @click="(evt) => counter++">add one</button>
</template>

<script lang="ts">
import { ref, computed, onBeforeMount, onMounted, reactive, onBeforeUpdate, watch } from 'vue';

export default {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    setup(): any {
        const vm = reactive({
            counter: 0,
            updatedTime: undefined as number | undefined
        });

        onBeforeMount(() => {
            watch(
                () => vm.updatedTime,
                (to) => {
                    console.log(`last update time: ${vm.updatedTime}`);
                }
            );
        });

        onMounted(() => {});

        onBeforeUpdate(() => {
            console.log('before update');
            vm.updatedTime = Date.now();
        });

        return vm;
    }
};
</script>

<style scoped lang="scss"></style>
avatar
Apr 10th 2021

I want to say that it has something to do with the execution timing of the watch

  • flush: 'pre': this is the default, in the scheduler and the effect will be called synchronously before the component mount.
  • flush: 'sync': out of the scheduler and the effect will be called synchronously
  • flush: 'post': in the scheduler and the effect will be called asynchronously after the component update

so the watch callback did not trigger the first time because vm.updatedTime not updated yet.

avatar
Apr 10th 2021

Thanks @edison1105 , OK I get this now. However, as the documents mentioned that

watch() and watchEffect() effects are run before the DOM is mounted or updated

using the following codes:

<template>
    <ul>
        <li :ref="(el) => (divs[i] = el)" v-for="(item, i) in lists" :key="item">
            {{ item }}
        </li>
    </ul>
    <button @click="(evt) => lists.push(lists.length)">add one</button>
</template>

<script lang="ts">
import { ref, computed, onBeforeMount, onMounted, reactive, onBeforeUpdate, watch } from 'vue';

export default {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    setup(): any {
        const vm = (window.vm = reactive({
            lists: [0, 1, 2, 3],
            divs: []
        }));

        onBeforeMount(() => {
            watch(
                () => vm.divs.length,
                (to) => {
                    console.log(`there are ${to} div`);
                }
            );
        });

        onMounted(() => {});

        onBeforeUpdate(() => {
            console.log('before update');
            vm.divs = [];
        });

        return vm;
    }
};
</script>

<style scoped lang="scss"></style>

Supposed that the watcher's side effect would only be fired once before mount, but the console shows me 4 lines of console log at the beginning of the page which were executed from the same watcher, before that, I didn't make any mutation.

i.e.

there are 1 div
there are 2 div
there are 3 div
there are 4 div
avatar
Apr 10th 2021

this is expected. because :ref="(el) => (divs[i] = el)" will be called 4 times in setRef during patch. In other words, the watch callback will be called synchronously 4 times.

and if change to this:

watch(
    () => vm.divs.length,
    (to) => {
        console.log(`there are ${to} div`);
    },
    {flush:'post'}
   );

I believe it will only output there are 4 div.

avatar
Apr 10th 2021

@edison1105 How come the watch callback will be called synchronously? I have tried to expose the vm and executed the following in console:

vm.divs.push('a');
vm.divs.push('b');
vm.divs.push('c');
vm.divs.push('d');

It only output there are 8 div once. How come the mutation during patch is synchronize and for other case, it is async?

avatar
Apr 10th 2021

they are different. executing vm.divs.push('a'); in the console will trigger the update logic of the component. before start update the component, the flushPreFlushCbs will be executed, and the jobs in the queue will be deduplicated. So only there are 8 div is output. If you are interested, you can add a breakpoint in the watch callback for debugging, and you will clearly see the entire process.

avatar
Apr 11th 2021

Re:

Supposed that the watcher's side effect would only be fired once before mount, but the console shows me 4 lines of console log at the beginning of the page which were executed from the same watcher, before that, I didn't make any mutation.

there are 1 div
there are 2 div
there are 3 div
there are 4 div

First of all, you should always use flush: 'post' to detect template ref changes.

  • Answer why you console output four times:

When doing the initial rendering, there is no so-called "flush queue". So a watch with pre option must be called synchronously, and this behavior is equivalent to having the sync option, this means it will not be deduplicated, so the console output four times. This only happens when you change state in the template, e.g. :ref="(el) => (divs[i] = el)".

  • Answer why the console only outputs once for subsequent state changes:

Because there is a flush queue at this time, and we can do de-duplication.

Again, you should always use flush: 'post' to detect template ref changes.

avatar
Apr 11th 2021

So clear, Thank you @edison1105 @HcySunYang