I am trying run a function be it computed or watch or watcheffect after the template has mounted.
the watcheffect only executes once, computed of course runs before mounted.
I have tried flush: 'post' in watcheffect and flush in watch, I am rather stuck.
looking at the docs it should work as expected:
https://v3.vuejs.org/guide/composition-api-template-refs.html#watching-template-refs
Therefore, watchers that use template refs should be defined with the flush: 'post' option. This will run the effect after the DOM has been updated and ensure that the template ref stays in sync with the DOM and references the correct element.
app.vue
<template>
<div ref="target">
<h1>my title</h1>
<p>Tenetur libero aliquam at distinctio.</p>
<h1>hello</h1>
<p class="fuckyeah yolo">quia nam voluptatem illum ratione ipsum.</p>
<img src="img.jpg" />
" title="hello" alt />
<h2>hello</h2>
<ol>
<li>hello inital</li>
<li v-for="i in inc">hello</li>
</ol>
</div>
<div>
<button #click="inc++">inc</button>
</div>
<pre>
<code>
{{ toJson }}
</code>
</pre>
</template>
<script>
import { ref } from '#vue/reactivity'
import { templateRef } from '#vueuse/core'
import { useParser } from './markcomposable.js'
import { onMounted, computed, watchEffect } from '#vue/runtime-core';
export default {
setup() {
const inc = ref(0);
const target = ref(null);
const { toJson } = useParser(target);
return {
inc, target, toJson
}
}
}
</script>
//composable.js
import { parse, validate } from "fast-xml-parser"
import { ref, reactive, watchEffect, toRef, nextTick } from 'vue'
const useParser = (target) => {
const toJson = ref(null);
const jsonOptions = reactive({
//defaults
attributeNamePrefix: "",
ignoreAttributes: false,
textNodeName: "text",
arrayMode: true
})
const dumpJson = (target, options) =>
validate(target.outerHTML) ? parse(target.outerHTML, options) : isValid.value;
watchEffect(() => {
if (target.value) {
toJson.value = dumpJson(target.value, jsonOptions)
console.log(toJson.value)
}
}, {
flush: 'post',
})
return {
target,
toJson,
}
}
export { useParser }
If I understand correctly, you're trying to observe the outerHTML of the template ref, and you're expecting the template-ref watcher to be invoked whenever you insert nodes (via the button callback), but it's only ever invoked once.
This happens because the watcher effectively only watches the template ref and not its properties. The watcher would only be invoked when the template ref is initialized with the component/element reference. Template refs cannot be reassigned, so the watcher would not be invoked again. Moreover, the template ref's properties are not reactive, so the watcher would not be invoked if the target node's HTML changed.
Solution
Instead of the watcher, use a MutationObserver to observe the changes made to the target node, including the outerHTML.
Create a function that uses a MutationObserver to invoke a callback:
const observeMutations = (targetNode, callback) => {
const config = { attributes: true, childList: true, subtree: true }
const observer = new MutationObserver(callback)
observer.observe(targetNode, config)
return observer
}
In an onMounted hook, use that function to observe the template ref in target.value, passing a callback that sets toJson.value:
let observer = null
onMounted(() => {
toJson.value = dumpJson(target.value.outerHTML, jsonOptions)
observer = observeMutations(target.value, () => {
toJson.value = dumpJson(target.value.outerHTML, jsonOptions)
})
})
In an onUnmounted hook, disconnect the observer as cleanup:
onUnmounted(() => observer?.disconnect())
demo
Just to clarify:
You're trying to pass a template ref to your function but it always turns out as null when you execute the logic, right?
You could simply use a onMounted(() => {}) hook in your composable.js file or you could implement templateRef (which you already tried to include from the looks of it) and until(https://vueuse.org/shared/until/#usage).
So instead of const target = ref(null) you'll do const target = templateRef('target', null) and pass that to your composable.js.
There you'll watch until the ref is truthy.
So before your actual logic you'll do this:
await until(unrefElement(target)).toBeTruthy()
Afterwards the ref should provide an actual element (use unrefElement to get the element of the templateRef) and you can start applying your actual logic to it.
Related
I want to react to external slot changes. In actual HTMLSlotElements I have slotchange events for that but it seems like that's not how that works in Vue 3. How can I watch my slot and react to any new elements being slotted?
This is what I tried:
<script setup lang="ts">
import { useSlots } from 'vue';
const slots = useSlots();
function handleSlotChange() {
console.log('The slotted content has changed to ', slots.foo?.()[0]);
}
</script>
<template>
<div>
<slot name="foo" #slotchange="handleSlotChange"></slot>
</div>
</template>
The slot element doesn't accept events, but you could use #vnodeUpdated event in the element that wraps the slot to watch the changes :
<template>
<div #vnodeUpdated="handleSlotChange">
<slot name="foo"></slot>
</div>
</template>
the vnodeUpdated event handler has the current element as parameter which has dynamicChildren as property which refers to the elements passed as slots.
Or as #matthew-e-brown said in comments try to use the watch with slots :
import { useSlots,watch } from 'vue';
const slots = useSlots();
watch(()=>slots.foo(),(v)=>{
console.log('slots changed')
},{
deep:true
})
Since Vue's slots are not real slots we can't use Vue-internal tools to react to slot changes. Vue's compatibility layer prevents that. Instead, we can use a MutationObserver:
let observer: MutationObserver;
onMounted(() => {
const el = document.querySelector('el-to-observe');
const getSlotContent = () => el.querySelector('[slot]')?.tagName || 'Nothing';
const callback = () => console.log(getSlotContent() + ' was slotted');
observer = new MutationObserver(callback);
observer.observe(el, {
childList: true,
subtree: true,
});
});
onUnmounted(() => observer?.disconnect());
If I run this code:
const state = reactive({
title: '',
})
watchEffect(() => {
console.log(state.title)
})
watchEffect is triggered and the console is outputting an empty string:
""
If I want to assign a new value to state.title, watchEffect is triggered twice. Is this behaviour expected or am I doing something wrong?
According to the documentation, watchEffect
Runs a function immediately [emphasis added] while reactively tracking its dependencies
and re-runs it whenever the dependencies are changed.
So it is expected that it should run twice in this situation: once when it is first defined, and then again when the value changes.
As #MykWillis pointed out and is clearly stated in the docs, watchEffect runs immediately and on subsequent changes, exactly as watch with { immediate: true } would.
If you do not want the effect to run initially, don't use watchEffect, use watch instead:
const { createApp, watchEffect, reactive, toRefs, watch } = Vue;
createApp({
setup() {
const state = reactive({
title: 'test',
});
watchEffect(() => {
console.log('watchEffect', state.title)
});
watch(() => state.title, () => {
console.log('watch', state.title)
});
watch(() => state.title, () => {
console.log('watch.immediate', state.title)
}, { immediate: true })
return { ...toRefs(state) }
}
}).mount('#app')
<script src="https://unpkg.com/vue#3/dist/vue.global.prod.js"></script>
<div id="app">
<input v-model="title">
</div>
I'm trying to set up a component with a slot, that when rendered adds a class to every children of that slot. In a very simplified manner:
<template>
<div>
<slot name="footerItems"></slot>
</div>
</template>
How would I go about this? My current solution is to add the class to the elements in an onBeforeUpdate hook:
<script setup lang="ts">
import { useSlots, onMounted, onBeforeUpdate } from 'vue';
onBeforeUpdate(() => addClassToFooterItems());
onMounted(() => addClassToFooterItems());
function addClassToFooterItems() {
const slots = useSlots();
if (slots && slots.footerItems) {
for (const item of slots.footerItems()) {
item.el?.classList.add("card-footer-item");
}
}
}
</script>
However, the elements lose the styling whenever it's rerendered (using npm run serve) and also jest tests give me a warning:
[Vue warn]: Slot "footerItems" invoked outside of the render function: this will not track dependencies used in the slot. Invoke the slot function inside the render function instead.
Should I move the slot to its own component and use a render function there? But even then I'm not sure how to edit the children to add the classes or how to produce several root level elements from the render function.
So, I managed to solve this in an incredibly hacky way, but at least my issue with re-rendering doesn't happen anymore and jest doesn't complain. I wrote a component with a render function that appends that class to all children
<template>
<render>
<slot></slot>
</render>
</template>
<script setup lang="ts">
import { useSlots } from 'vue';
const props = defineProps<{
childrenClass: string;
}>();
function recurseIntoFragments(element: any): any {
if (element.type.toString() === 'Symbol(Fragment)'
&& element.children[0].type.toString() === 'Symbol(Fragment)'
) {
return recurseIntoFragments(element.children[0]);
} else {
return element;
}
}
const render = () => {
const slot = useSlots().default!();
recurseIntoFragments(slot[0]).children.forEach((element: any) => {
if (element.props?.class && !element.props?.class.includes(props.childrenClass)) {
element.props.class += ` ${props.childrenClass}`;
} else {
element.props.class = props.childrenClass;
}
});
return slot;
}
</script>
Then I would just wrap the slot in this component to add the class to the children elements:
<template>
<div>
<classed-slot childrenClass="card-footer-item">
<slot name="footerItems"></slot>
</classed-slot>
</div>
</template>
I would gladly accept any answer that improves upon this solution, especially:
Any tips to type it. All those anys feel wonky but I find it very impractical working with Vue types for slots since they are usually unions of 3 or 4 types and the only solution is to wrap these in type checks
Anything that improves its reliability since it seems that it'd crash in any slightly different setup than the one I intended
Any recommendation based on Vue's (or TS) best practices, since this looks very amateurish.
Really any other way to test for symbol equality because I know none
EDIT
This is my latest attempt, a render function in a file ClassedSlot.js:
import { cloneVNode } from 'vue';
function recursivelyAddClass(element, classToAdd) {
if (Array.isArray(element)) {
return element.map(el => recursivelyAddClass(el, classToAdd));
} else if (element.type.toString() === 'Symbol(Fragment)') {
const clone = cloneVNode(element);
clone.children = recursivelyAddClass(element.children, classToAdd)
return clone;
} else {
return cloneVNode(element, { class: classToAdd });
}
}
export default {
props: {
childrenClass: {
type: String,
required: true
},
},
render() {
const slot = this.$slots.default();
return recursivelyAddClass(slot, this.$props.childrenClass);
},
};
The usage of this component is exactly the same as in the previous one. I'm kinda happy with this solution, seems more robust and idiomatic. Note that it's javascript because I found it really hard to type these functions correctly.
#Haf 's answer is good.
I made some adjustments, so that you can specify the component name.
import { FunctionalComponent, StyleValue, cloneVNode, Ref, ComputedRef, unref } from "vue";
type MaybeRef<T> = T | Ref<T>;
/**
* #param extraProps - ref or normal object
* #returns
*
* #example
*
* const StyleComponent = useCssInJs({class: 'text-red-500'});
*
* const ComponentA = () => {
* return <StyleComponent><span>text is red-500</span></StyleComponent>
* }
*/
export const useCssInJs = (extraProps: MaybeRef<{
class?: string,
style?: StyleValue
}>) => {
const FnComponent: FunctionalComponent = (_, { slots }) => {
const defaultSlot = slots.default?.()[0];
// could be Fragment or others? I'm just ignoring these case here
if (!defaultSlot) return;
const node = cloneVNode(defaultSlot, unref(extraProps));
return [node];
};
return FnComponent
}
this is my component called Musics.vue:
<template>
<div class="flex flex-wrap mb-20 md:mb-32">
<div
v-for="(music, index) in musics"
:key="index"
class="w-full sm:w-6/12 lg:w-3/12 p-3"
>
<MusicCard :music="music" #play="setCurrent($event)" />
</div>
</div>
</template>
as you see MusicCard is in the loop. and each MusicCard emit play event to parent component. have can i write test for it? (i tried to use forEach but it failed)
this is my test:
it("commits a mutation when 'MusicCard' component emits play event", () => {
const components = wrapper.findAllComponents({ name: "MusicCard" });
expect(components.exists()).toBe(true);
});
thanks for your helping.
You probably need to decompose your test in several simple tests.
Assuming that you mount your component with either imported mutations or mocked mutations, you should be able to do something like:
// import { mutations } from "#/store/MyAppStore.js"
// or:
const mutations = {
myMutation: jest.fn()
}
const store = new Vuex.Store({ mutations })
const wrapper = mount(Musics, {
store, localVue
})
describe("When Musics component is mounted, it:", () => {
it("lists several music cards", () =>
{
const components = wrapper.findAllComponents({ name: "MusicCard" });
expect(components.length).toBeGreaterThan(1);
})
it("receive a play event from the 'MusicCard' components", () =>
{
// assert event has been emitted
expect(wrapper.emitted().myPlayEvent).toBeTruthy()
// assert event count
expect(wrapper.emitted().myPlayEvent.length).toBe(2)
// assert event payload
expect(wrapper.emitted().myPlayEvent[1]).toEqual([123])
})
it("commits a mutation when 'MusicCard' component emits play event", async () =>
{
wrapper.vm.$emit('myPlayEvent' /*, payload */)
await wrapper.vm.$nextTick()
expect(mutations.myMutation).toHaveBeenCalled()
// assert payload
expect(mutations.myMutation).toHaveBeenCalledWith(payload)
})
})
I'm importing a plain class to my react (functional) component and want to be notified when an imported class property is set/updated. I've tried setting my imported class with just new, as a state variable with useState, as a ref with useRef - and have tried passing each one as a parameter to useEffect, but none of them are triggering the useEffect function when the property is updated a second time.
I've excluded all other code to drill down to the problem. I'm using Typescript, so my plain vanilla MyClass looks like this:
class MyClass {
userId: string
user: User?
constructor(userId: string){
this.userId = userId
// Do a network call to get the user
getUser().then(networkUser => {
// This works because I tried a callback here and can console.log the user
this.user = networkUser
}).catch(() => {})
}
}
And then in my component:
// React component
import { useEffect } from 'react'
import MyClass from './MyClass'
export default () => {
const myClass = new MyClass(userId)
console.log(myClass.user) // undefined here
useEffect(() => {
console.log(myClass.user) // undefined here and never called again after myClass.user is updated
}, [myClass.user])
return null
}
Again, this is greatly simplified. But the problem is that React is not re-rendering my component when the instance user object is updated from undefined to a User. This is all client side. How do I watch myClass.user in a way to trigger a re-render when it finally updates?
Let me guess you want to handle the business logic side of the app with OOP then relay the state back to functional React component to display.
You need a mechanism to notify React about the change. And the only way for React to be aware of a (view) state change is via a call to setState() somewhere.
The myth goes that React can react to props change, context change, state change. Fact is, props and context changes are just state change at a higher level.
Without further ado, I propose this solution, define a useWatch custom hook:
function useWatch(target, keys) {
const [__, updateChangeId] = useState(0)
// useMemo to prevent unnecessary calls
return useMemo(
() => {
const descriptor = keys.reduce((acc, key) => {
const internalKey = `##__${key}__`
acc[key] = {
enumerable: true,
configurable: true,
get() {
return target[internalKey]
},
set(value) {
if (target[internalKey] !== value) {
target[internalKey] = value
updateChangeId(id => id + 1) // <-- notify React about the change,
// the value's not important
}
}
}
return acc
}, {})
return Object.defineProperties(target, descriptor)
},
[target, ...keys]
)
}
Usage:
// React component
import { useEffect } from 'react'
import { useWatch } from './customHooks'
import MyClass from './MyClass'
export default () => {
const myClass = useMemo(() => new MyClass(userId), [userId])
useWatch(myClass, ['user'])
useEffect(() => {
console.log(myClass.user)
}, [myClass, myClass.user])
return null
}
Side Note
Not related to the question per se, but there're a few words I want to add about that myth I mentioned. I said:
props and context changes are just state change at a higher level
Examples:
props change:
function Mom() {
const [value, setValue] = useState(0)
setTimeout(() => setValue(v => v+1), 1000)
return <Kid value={value} />
}
function Dad() {
let value = 0
setTimeout(() => value++, 1000)
return <Kid value={value} />
}
function Kid(props) {
return `value: ${props.value}`
}
context change:
const Context = React.createContext(0)
function Mom() {
const [value, setValue] = useState(0)
setTimeout(() => setValue(v => v+1), 1000)
return (<Context.Provider value={value}>
<Kid />
</Context.Provider>)
}
function Dad() {
let value = 0
setTimeout(() => value++, 1000)
return (<Context.Provider value={value}>
<Kid />
</Context.Provider>)
}
function Kid() {
const value = React.useContext(Context)
return `value: ${value}`
}
In both examples, only <Mom /> can get <Kid /> to react to changes.
You can pass this.user as props and use props,.user in useEffeect. You could do that from the place getUser called.
A wholesome solution would be using a centralized state solution like redux or context API. Then you need to update store in getUser function and listen globalstate.user.
Conclusion
You need to pass this.user to the component one way or another. You need to choose according to the project.