Why would a Vue3 watcher of a prop not be triggered? (Composition API) - vue.js

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, ...)

Related

Why does watchEffect trigger while the watched value is not changed?

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

Vue 3 Mutate Composition API state from external script

Forewords
In Option API, I was able to directly mutate instance data properties without losing any of reactivity. As described in here.
If you ask why, well not everything is written in Vue and there're cases where external JS libraries have to change certain value inside Vue instance.
For example:
document.app = createApp({
components: {
MyComponent, //MyComponent is a Option API
}
})
//Somewhere else
<MyComponent ref="foo"/>
Then component state can be mutated as follow:
//Console:
document.app.$refs.foo.$data.message = "Hello world"
With the help of ref, regardless of component hiarchy, the state mutating process is kept simple as that.
Question
Now in Composition API, I want to achieve the same thing, with setup script if it's possible.
When I do console.log(document.app.$refs), I just only get undefined as returned result.
So let's say I have MyComponent:
<template>
{{message}}
<template>
<script setup>
const message = ref('Hello world');
</script>
How to mutate this child component state from external script? And via a ref preferably, if it's easier
Refs that are exposed from setup function are automatically unwrapped, so a ref can't be changed as a property on component instance.
In order for a ref to be exposed to the outside, this needs to be explicitly done:
setup(props, ctx) {
const message = ref('Hello world');
ctx.expose({ message });
return { message };
}
This is different in case of script setup because variables are exposed on a template but not component instance. As the documentation states:
An exception here is that components using are private by default: a parent component referencing a child component using won't be able to access anything unless the child component chooses to expose a public interface using the defineExpose macro
It should be:
<script setup>
...
const message = ref('Hello world');
defineExpose({ message });
</script>

How to test my tooltip component using Vue.js and Jest?

I'm trying to test my tooltip component, but it seems it does not exist :cry:
My .html
<div>
<boxComponent>
Some text
<tooltipComponent
#mouseover.native="handleHover(true)"
#mouseleave.native="handleHover(false)"
>This text appears on Hover</tooltipComponent>
</boxComponent>
<switchComponent button-type="button" :label="false" #change="activeFun" />
</div>
My .js
methods: {
handleHover (s) {
this.onHoverTooltip = s
},
}
My .spec.js
const localVue = createLocalVue()
localVue.use(Vuex)
//...
it('should reveal tooltip\'s mesage', () => {
const wrapper = shallowMount(ozFilters, {
propsData: {
//others stuffs,
label: false,
},
localVue,
store,
stubs: ['tooltipComponent', 'boxComponent', 'switchComponent'],
})
expect(wrapper.find('tooltipComponent-stub').exists()).toBeFalsy()
// wrapper.vm.label = true
wrapper.vm.handleHover(true)
expect(wrapper.find('tooltipComponent-stub').exists()).toBeTruthy()
})
I need to understand what should I do to test the tooltip component that is already a custom component.
Even without the -stub it does not work.
The error is occurring in this line expect(wrapper.find('tooltipComponent-stub').exists()).toBeTruthy() with says that the expect is false.
Well, there are a couple of things that need to be fixed/clarified.
First of all, you are using shallowMount to create a component which you want to test, it stubs all custom components provided in tested component so you don't have to additionally use stub parameter to stub them, you can easily delete this: stubs: ['tooltipComponent', 'boxComponent', 'switchComponent'].
When you want to find specific component it's recommended to use findComponent instead of just find. (Using find to search for a component is actually deprecated). To properly identify component it's best to import it in your test file and use it in findComponent argument, something like this:
import BoxComponent from 'path/to/BoxComponent'
it('lovely test', () => {
wrapper.findComponent(BoxComponent)
})
After this your test should pass, but there are some things to consider like using mount instead of shallowMount. It's explained here, but to wrap it up, mount will render actual component with its children, where shallowMount stubs components inside tested component, so you are not testing the actual component but some "shadow" of its true nature. To see a difference between this functions I would recommend to create a wrapper with both functions and then see what html() will return from them.

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

How can I import an other custom component using Vue composition API?

I'm trying to create a Vue 3 component library using composition API:
https://github.com/hyperbotauthor/vue3complib
In one of the components I would like to import an other composition API component ( https://github.com/hyperbotauthor/vue3complib/blob/main/src/components/ChessboardExt.vue ):
import { Perscombo } from "../index"
const PerscomboE = (Perscombo as any).setup
const e = PerscomboE({id: "variant", options: variants}, context)()
const vertContainer = h(
"div",
{
},
[e, outerContainer]
);
This almost works, because the component's node is created with its setup function, and it is even rendered on the page correctly, however its onMounted function does not get called properly and I get the warning
onMounted is called when there is no active component instance to be associated with.
Lifecycle injection APIs can only be used during execution of setup().
If you are using async setup(), make sure to register lifecycle hooks before the first await statement.
Not only a warning, but unfortunately I need this for initializing the component, so it is not fully functional without its onMounted function as it should be persistent and its state cannot be initialized from localStorage.
How do I import an other composition API component into my composition API component's setup properly?
EDIT:
Managed to remove onMounted from the child component and I can pass a callback in props for the case when its state changes. So for this case I solved the issue. In general I still don't know the solution.
You can pass an imported custom component via the h function, like an HTML tag. Event handling works the same way as with HTML elements: You prefix the handler name with on, use camel case, and pass the handler as a property with this name:
const variantCombo = h(Perscombo, {id: props.id + "/variant", options: variants, onPerscombochanged: (event:any) => {
setVariant()
}})
const sizeCombo = h(Perscombo, {id: props.id + "/size", options: sizes, onPerscombochanged: (event:any) => {
resize(event.value)
}})
const upperControls = h(
"div",
{
},
[variantCombo, sizeCombo]
);