Why does watchEffect trigger while the watched value is not changed? - vue.js

I have a component which keeps a local partial copy of an Pinia storage data.
<template>
<h3>Order idx {{ idx }}: sum = {{ localSum }}, store sum = {{ getOrderSum() }}</h3>
<input type="number" v-model="localSum" />
<button #click="updateSum">Save</button>
</template>
<script setup>
import { useCounterStore } from '../store/counter'
import { watchEffect, ref, defineProps, watch } from 'vue'
const props = defineProps({
idx: 0
})
const store = useCounterStore()
const localSum = ref(0)
function getOrderSum() {
return store.getOrderIdxSumMap[props.idx]
}
function updateSum() {
store.setOrderSum(props.idx, localSum.value)
}
watch(
() => getOrderSum(),
(newValue) => {
console.log('i am updated')
localSum.value = newValue
}, {
immediate: true,
}
)
/*
watchEffect(() => {
console.log('i am updated')
localSum.value = getOrderSum()
})
*/
</script>
Whenever external data changes the local copy should update. Using watchEffect instead of watch causes components with modified and unsaved data to lose user input.
watchEffect behaviour description 1:
Change first order data
Click save
You'll see i am updated twice within console.
watchEffect behaviour description 2:
Change the first order data
Change the second order data
Click save on the first order
You'll see the second order changes lost
Comment out watchEffect and uncomment watch. Now everything works just fine. Is it my misconseptions or a bug worth to be reported?
Full demo

It is how watch and watchEffectsupposed to work
In short, watch and watchEffect both tracks the change of their dependencies. When the dependencies changed they act like follow:
watch recalculates its source value and then compares the oldValue with newValue. If oldValue !== newValue, it will trigger the callback.
watchEffect has no sources so it triggers the callback anyway
In your example the dependencies of both watch and watchEffect are store.getOrderIdxSumMap and props.idx. The source of watch is the function () => getOrderSum(). So when the store.getOrderIdxSumMap changed, watchEffect will always trigger the callback but watch will re-calculate the value of () => getOrderSum(). If it changed too, watch will trigger its callback
When Vue detects a dependency is changed?
Whenever you set a value for reactive data, Vue uses a function to decide if the value is changed as follows:
export const hasChanged = (value: any, oldValue: any): boolean =>
!Object.is(value, oldValue)
So setting the same value for a primitive type (number, string, boolean...) will not be considered as a value change

Related

How can I reset the value of a ref and keep an associated watcher working?

UPDATE:
I have achieved the desired behavior in the MCV by changing resetArray:
function resetArray() {
// myArray.value = [] // old version
myArray.value.length = 0 // new version
}
But I still don't understand why my MCV doesn't work.
ORIGINAL POST:
Background
In an app I am building, I store data in an a ref, created as const myArray = ref([]), which takes the form of an array of objects. This array is only changed in the following ways:
myArray.value[index] = {key: value}
myArray.value = [].
In particular, at no time is an object in myArray modified, it is either created or replaced.
I later added a watch which took action on every change to myArray.value. I discovered that after resetting myArray to [], the watcher stopped getting called.
Things I have tried:
I confirmed that my usage of ref follows the guidelines in this SO answer regarding ref vs reactive.
Refactoring to use watchEffect instead of watch. Did not help.
Refactoring to use reactive rather than ref. Did not help.
My Issue
In the MCV below, modifying myArray by calling addToArray works as intended: myArray.length is rendered and the first watch is triggered.
Calling resetArray triggers only the second watch, but the first watch IS NOT triggered when addToArray is called afterwards.
My Question
How can I both keep the ability to set myArray to [] and trigger actions every time myArray changes?
My MCV
View my MCV on Vue SFC Playground
The below code is the content of App.vue in a Vue project created with npm init vue#latest:
<script setup>
import {ref, watch} from "vue"
const myArray = ref([])
function addToArray() {
myArray.value.push("1")
}
function resetArray() {
myArray.value = []
}
watch(myArray.value, () => {
console.log("CLICKED!")
})
watch(myArray, () => {
console.log("RESET! clicked won't get called again!")
})
</script>
<template>
{{myArray.length}}<br />
<button #click="addToArray">CLICK ME</button><br />
<button #click="resetArray">RESET</button>
</template>
When watching a ref, use the ref itself -- not its value property -- as the watch source (the 1st argument to watch()).
To observe new array assignments or item additions/removals, pass the deep:true option (the 3rd argument to watch()):
watch(
myArray 1️⃣,
() => { /* handle change */ },
{ deep: true } 2️⃣
)
demo

Why would a Vue3 watcher of a prop not be triggered? (Composition API)

I am desperately trying to watch a prop in Vue3 (3.2.31, Composition API):
<script lang="ts" setup>
import { toRef, watch } from 'vue'
const props = defineProps({
'trigger-refresh': {
type: String
}
})
const triggerRefresh = toRef(props, 'trigger-refresh')
watch(triggerRefresh, (a, b) => console.log('props triggered refresh'))
</script>
I trigger the emission of trigger-refresh, it is passed to the component with the code above and I see triggerRefresh changing in DevTools→Vue.
So everything is fine up to the inside of the component except that the watch is not triggered (i.e. there is no message on the console).
I read the documentation for Watchers and responses to a question about watching props (one of the responses is almost identical to what I did) but I simply fail to understand why this does not work in my case.
Try to add the immediate option in order to trigger the watch at the first prop change:
watch(triggerRefresh, (a, b) => console.log('props triggered refresh'), {
immediate: true
})
Prop names are normalized to camel case. As it was stated in the question, it is triggerRefresh and not trigger-refresh that is seen updated. So it should be:
const triggerRefresh = toRef(props, 'triggerRefresh')
watch(triggerRefresh, ...)
Or just:
watch(() => props.triggerRefresh, ...)

How to tell whether a prop has been explicitly passed in to a component in Vue 3

Is there any way to tell, from within a Vue 3 component, what props are being explicitly passed in (i.e., as attributes on the component's tag in the parent template)—as opposed to being unset and receiving their value from a default?
In other words, if I have a prop declared with a default value, like this:
props: {
name: {
type: String,
default: 'Friend'
}
}
How can I tell the difference between this:
<Greeting name="Friend" />
and this:
<Greeting /> <!-- name defaults to 'Friend' -->
In both instances, the component's name prop will have the same value, in the first case being passed in explicitly and in the second case being assigned the default.
Is there a systematic way in Vue 3 to determine the difference?
In Vue 2 you could use this.$options.propsData for this purpose—it contained only the properties that were directly passed in. However, it has been removed in Vue 3.
This information may be available in component instance with getCurrentInstance low level API, but there is no documented way to distinguish default and specified prop value that are === equal.
If default prop value is supposed to differ from the same value that was specified explicitly, this means that this isn't the case for default prop value, and it should be specified in some other way, e.g. through a computed:
const name = computed(() => {
if (props.name == null) {
console.log('default');
return 'Friend';
} else
return props.name;
});
There is a way, I believe, although since it's a pretty specialised requirement, we need to directly access Vue's undocumented methods and properties, which is not considered stable (although usually fine).
<script setup>
import { getCurrentInstance, computed } from "vue";
const props = defineProps({
test: {
type: String,
default: "Hello"
}
});
const vm = getCurrentInstance();
const propDefaults = vm.propsOptions[0];
const testIsDefault = computed(() => propDefaults.test.default === props.test);
</script>

How update vue variable if API variable changed?

I would like to use VUE in combination with Pano2VR. Pano2VR has an API (https://ggnome.com/doc/javascript-api/) that allows me to use the value of a variable from Pano2VR in Vue.
I programmed a code that finds the value of a variable from Pano2VR and assigns that value to the VUE.
If I change the value of a variable in VUE, I can also change it in Pano2VR.
The problem, however, is that I need that if the value of a variable in Pano2VR changes, it also changes automatically in VUE.
The code I have below is what works for me so far, except for updating the values in Vue, if the value of the variable in Pano2VR changes.
Can someone help me how to do it ? May thanks for your time and help.
const app = new Vue ({
el: '#app',
data: {
dude: pano.getVariableValue('vue_text')
// save value from variable "vue_text" from Pano2VR into dude
},
methods: {
update: function () {
this.dude = 'Lama lamová',
pano.setVariableValue('vue_text', this.dude);
// update Pano2VR variable
}
},
})
<div id="app" #click="update">
{{ dude }}
</div>
It seems Pano2VR has a varchanged_VARNAME event that occurs for variable changes. You could hook into that to update Vue:
const app = new Vue ({
mounted() {
pano.on('varchanged_vue_text', () => {
this.dude = pano.getVariableValue('vue_text')
})
}
})

Why vue need to forceUpdate components when they include static slot

Why vue needs to forceUpdate child component that has a static slot when it update self
It will trigger too much update calculate when a component has lots of child components that has a static slot
// my-button.vue
<template>
<div>
<slot></slot>
</div>
</template>
// my-com.vue
<template>
<div>
<span>{{ foo }}</span>
<template v-for="(item, index) in arr">
<my-button>test</my-button>
</template>
</div>
</template>
<script>
export default {
data() {
return {
foo: 1,
arr: (new Array(10000)).fill(1)
}
}
}
</scirpt>
If run this.foo = 2 will lead update queue include 10000 watcher. When I read source code I found the following code
function updateChildComponent (
...
// Any static slot children from the parent may have changed during parent's
// update. Dynamic scoped slots may also have changed. In such cases, a forced
// update is necessary to ensure correctness.
const needsForceUpdate = !!(
renderChildren || // has new static slots
vm.$options._renderChildren || // has old static slots
hasDynamicScopedSlot
)
...
// resolve slots + force update if has children
if (needsForceUpdate) {
vm.$slots = resolveSlots(renderChildren, parentVnode.context)
vm.$forceUpdate()
}
...
}
I have found this issue on GitHub.
Unfortunately, any child components with static slot content still
need to be forced updated. This means the common use case of
<parent><child></child></parent> doesn't benefit from this change,
unless the default slot is explicitly forced into a scoped slot by
using <parent v-slot:default><child></child></parent>. (We cannot
directly force all slots into scoped slots as that would break
existing render function code that expects the slot to be present on
this.$slots instead of this.$scopedSlots)
Seems like it's fixed in 2.6.
In 2.6, we have introduced an optimization that further ensures parent
scope dependency mutations only affect the parent and would no longer
force the child component to update if it uses only scoped slots.
To solve your problem just update your Vue version to 2.6. Since it's just a minor update nothing will break down. What about the reason to call forceUpdate - only Evan You knows that :)
Hello i solved this problem with this:
export function proxySlots(scopedSlots: any): any {
return Object.keys(scopedSlots).reduce<any>(
(acc, key) => {
const fn = scopedSlots[key];
fn.proxy = true;
return { ...acc, [key]: fn };
},
{ $stable: true },
);
}
const ctx = {
// ...
scopedSlots: proxySlots({ someSlot: () => <span>Hello</span>})
// or from provided slots : this.$scopedSlots or context.slots if using composition api
}
It's a bit hackish but no more forced update.