I have to build a Vue.JS application that displays some components that are unknown at the Webpack compilation time. Indeed, components to load will depend on customer configuration and on modules loaded on the server side.
The solution I was thinking of was to create a JavaScript compiled file for each of those components and to load them in the main component thanks to the defineAsyncComponent method:
let child = defineAsyncComponent({
loader: () => import(/* webpackIgnore: true */ 'http://localhost:8000/build/myExternalComponent.js'),
loadingComponent: () => 'Loading...',
errorComponent: () => 'Error',
delay: 200,
timeout: 3000,
});
Everything seems to work nicely… Expected when those external components have to be refreshed because one of its references changed. Here is an example :
Initial sate of my POC
On this page, we can see two Vue.JS components that are almost the same. Bof of them hold a reference on a variable that is displayed on the “Counter : x” line, and can be increased when we click on the “increment” button. The host one is directly mounted in a DOM element of my page in a script loaded in its head ; and the child is loaded asynchronously from the host and displayed inside it. The child also holds a prop that contains the counter value of the host component and display it on the “Parent counter: x” line.
Now, if we click on the “Increment” button of the child component, nothing is updated in the DOM of the webpage. :(
If we update child values, no changes are visible
But if we click on the one in the host component, both host and child component are updated.
If we update host values, both components are refreshed
If I remove the prop on the parent counter in the child, the child component is never updated anymore, even when the parent changes… It thus looks like Vue.JS is not triggering its rendering process when a reference for a child changes, but if someone else triggers it, the rendering is correct.
The proof of concept that illustrates my issue is available here. You should be able to experiment it easily with this repo.
Here are some samples of the source code of the repo.
Webpack configuation (main parts)
[…]
module.exports = {
mode: 'development',
entry : {
'host': './entries/host.js',
'child': './entries/child.js',
},
experiments: {
outputModule: true,
topLevelAwait: true,
},
output : {
path: path.resolve(__dirname, 'public', 'build'),
publicPath: '/build/',
filename: '[name].js',
library: {
type: 'module'
}
},
module: {
rules: [
{
test: /\.vue$/,
loader: 'vue-loader'
}
]
},
devtool: 'source-map',
plugins: [
new VueLoaderPlugin(),
new webpack.DefinePlugin({
'__VUE_OPTIONS_API__': true,
'__VUE_PROD_DEVTOOLS__': false,
}),
new ModuleFederationPlugin({
shared: {
vue: {
singleton: true
}
}
}),
]
}
Host component implementation
<script setup>
import {defineAsyncComponent, ref} from "vue";
let child = defineAsyncComponent({
/**
* In the real application, the component location will be generated dynamically at the execution depending on
* customer configuration.
*/
loader: () => import(/* webpackIgnore: true */ 'http://localhost:8000/build/child.js'),
loadingComponent: () => 'Loading...',
errorComponent: () => 'Error',
delay: 200,
timeout: 3000,
});
const counter = ref(0);
const increment = () => counter.value++;
</script>
<template>
<div>
<h2>In host component.</h2>
<p>Counter : {{ counter }}</p>
<p #click="increment">Increment</p>
<hr />
<component :is="child" :parentCounter="counter" />
</div>
</template>
Child component implementation
<script setup>
import { ref, defineProps } from 'vue';
defineProps({
parentCounter: Number
})
const counter = ref(0);
const increment = () => counter.value++;
</script>
<template>
<div>
<h2>In child async component</h2>
<p>Counter : {{ counter }}</p>
<p>Parent counter: {{ parentCounter }}</p>
<p #click="increment">Increment</p>
</div>
</template>
Does anyone have a clue about what I am doing wrong?
Related
I'm trying to follow the guide here to test an emitted event.
Given the following Vue SFC:
<script setup>
</script>
<template>
<button data-testid="credits" #click="$emit('onCredits')">Click</button>
</template>
and the following Cypress test:
import { createTestingPinia } from '#pinia/testing';
import Button from './Button.vue';
describe('<Button />', () => {
it('renders', () => {
const pinia = createTestingPinia({
createSpy: cy.spy(),
});
cy.mount(Button, {
props: {
onCredits: cy.spy().as('onCreditsSpy'),
},
global: {
plugins: [pinia],
},
});
cy.get('[data-testid=credits]').click();
cy.get('#onCreditsSpy').should('have.been.called');
});
});
My test is failing with
expected onCreditsSpy to have been called at least once, but it was never called
It feels weird passing in the spy as a prop, have I misunderstood something?
I solved such a situation with the last example within Using Vue Test Utils.
In my case the PagerElement component uses the properties 'pages' for the total of pages to render and 'page' for the current page additional to the 'handleClick'-Event emitted once a page has been clicked:
cy.mount(PagerElement, {
props: {
pages: 5,
page: 0
}
}).get('#vue')
Within the test I click on the third link that then emmits the Event:
cy.get('.pages router-link:nth-of-type(3)').click()
cy.get('#vue').should(wrapper => {
expect(wrapper.emitted('handleClick')).to.have.length
expect(wrapper.emitted('handleClick')[0][0]).to.equal('3')
})
First expectation was for handleClick to be emitted at all, the second one then checks the Parameters emitted (In my case the Page of the element clicked)
In order to have the Wrapper-element returned a custom mount-command has to be added instead of the default in your component.ts/component.js:
Cypress.Commands.add('mount', (...args) => {
return mount(...args).then(({ wrapper }) => {
return cy.wrap(wrapper).as('vue')
})
})
I'm new to unit testing in Vue and I use #vue/test-utils. Here is my Foo.vue
<template>
<div class="hello">
<b-table striped hover :items="items"></b-table>
</div>
</template>
<script>
export default {
name: "Foo",
data() {
return {
items: [
{
id: 0,
name: "foo",
},
{
id: 1,
name: "bar",
},
],
};
},
};
</script>
And here is my Foo.spec.js file for testing Foo.vue component:
import { shallowMount,createLocalVue } from '#vue/test-utils'
import Foo from '#/components/Foo.vue'
import {BootstrapVue} from "bootstrap-vue"
const localVue = createLocalVue();
localVue.use(BootstrapVue);
describe('Foo.vue', () => {
it('renders bootstrap table', () => {
const wrapper = shallowMount(Foo, {localVue})
expect(wrapper.contains("b-table")).toBe(true)
})
})
When I run the test I get the error;
● Foo.vue › renders bootstrap table
expect(received).toBe(expected) // Object.is equality
Expected: true
Received: false
even though there must be a b-table element mounted. When I replace the code piece expect(wrapper.contains("b-table")).toBe(true) with expect(wrapper.contains("b-table-stub")).toBe(true) I didn't encounter any error.
Additionally when I remove the localVue asset from the shallowMount function like,
const wrapper = shallowMount(Foo, {localVue})
expect(wrapper.contains("b-table")).toBe(true)
no error remains and test case runs smoothly.
My question is that why b-table, b-pagination, etc. (b-x) elements are mounted as b-table-stub? Am I forced to check all b-x elements like b-x-stub in test cases or is there any shortcut for this?
You used shallowMount() to mount your component "Foo".
Per vue-test-utils v2 docs shallowMount stubs child components so that you can focus on testing component Foo's logic without the need to mount child components and their children. This makes testing Foo's specific logic easier. Moreover, it makes the test more efficient since we are essentially building the DOM in memory and we can avoid unnecessarily rendering too much in memory by doing this.
If you need to test b-table, use mount(Foo) instead. Further, if you want to test b-table only and ignore other components you can stub other child components like so:
mount(Foo, {
...
stubs: {
childA: true,
childB: true
},
...
})
I am relatively new to vue and have run into a small issue. I am rendering a component that depends on the state stored in vuex. I load this information in from a json file in the main part of the app. It all works fine if I always land on the root (index.html) of the app when it loads up. However, if I refresh the app from a page that is dynamically generated from the router I hit an error:
[Vue warn]: Error in render: "TypeError: Cannot read property 'name' of undefined"
found in
---> <Room>
<RoomsOverview>
<Root>
As far as I can tell what is happening is that that the component is trying to access the state in vuex but it has not been initialised. Here is the component (Room.vue):
<template>
<div id="room">
<h2>{{ roomName }}</h2>
<div v-for="device in deviceList" v-bind:key="deviceList.name">
{{ device.name }} - {{ device.function}}
<svg-gauge v-bind:g-value="device.value" v-bind:g-min="0" v-bind:g-max="50" v-bind:g-decplace="1" g-units="℃">
<template v-slot:title>
Temperature
</template>
</svg-gauge>
</div>
</div>
</template>
<script>
module.exports = {
name: 'room',
/** Load external component files
* Make sure there is no leading / in the name
* To load from the common folder use like: 'common/component-name.vue' */
components: {
'svg-gauge': httpVueLoader('components/DisplayGauge.vue'),
}, // --- End of components --- //
data() {
return {
};
},
computed: {
roomName() {
// return this.$route.params.roomId;
return this.$store.getters['rooms/getRoomById'](this.$route.params.roomId);
},
deviceList() {
return this.$store.getters['rooms/getDevicesinRoom'](this.$route.params.roomId);
},
},
}
</script>
The error is triggered by the line
return this.$store.getters['rooms/getRoomById'](this.$route.params.roomId);
This tries to access the current state in the getter:
getRoomById: (state) => (id) => {
return state.rooms.find(room => room.id === id).name; // Needs fixing!
},
but it seems that the array:
// Initial state
const stateInitial = {
rooms: [],
};
has not been initialised under these circumstances. The initialisation occurs in the main entry point to the app in index.js in the mounted hook
// Load data from node-red into state
vueApp.$store.dispatch('rooms/loadRooms')
Where loadRooms uses axios to get the data. This works as expected if I arrive at the root of the site (http://192.168.0.136:1880/uibuilderadvanced/#/) but not if I arrive at a link such as (http://192.168.0.136:1880/uibuilderadvanced/#/rooms/office). I suspect it is all down to the order of things happening and my brain has not quite thought things through. If anyone has any ideas as to how to catch this I would be grateful - some kind of watcher is required I think, or a v-if (but I cannot see where to put this as the Room.vue is created dynamically by the router - see below).
Thanks
Martyn
Further information:
The room component is itself generated by router-view from within a parent (RoomsOverview.vue):
<template>
<div id="rooms">
<b-alert variant="info" :show="!hasRooms">
<p>
There are no rooms available yet. Pass a message that defines a room id and device id
to the uibuilder node first. See <router-link :to="{name: 'usage_info'}">the setup information</router-link>
for instructions on how start using the interface.
</p>
</b-alert>
<router-view></router-view>
</div>
</template>
<script>
module.exports = {
name: 'RoomsOverview',
data() {
return {
};
},
computed: {
hasRooms() {
return this.$store.getters['rooms/nRooms'] > 0;
},
roomList() {
return this.$store.getters['rooms/getAllRooms'];
},
},
}
</script>
and is dependent on the router file:
const IndexView = httpVueLoader('./views/IndexView.vue');
const AdminView = httpVueLoader('./views/AdminView.vue');
export default {
routes: [
{
path: '/',
name: 'index',
components: {
default: IndexView,
menu: HeaderMenu,
},
},
{
path: '/rooms',
name: 'rooms_overview',
components: {
default: httpVueLoader('./components/RoomsOverview.vue'),
menu: HeaderMenu,
},
children: [
{
path: ':roomId',
name: 'room',
component: httpVueLoader('./components/Room.vue'),
},
],
},
{
path: '/admin',
name: 'admin',
components: {
default: AdminView,
menu: HeaderMenu,
},
children: [
{
path: 'info',
name: 'usage_info',
component: httpVueLoader('./components/UsageInformation.vue'),
}
]
},
],
};
It seems you already got where the issue is.
When you land on you main entry point, the axios call is triggered and you have all the data you need in the store. But if you land on the component itself, the axios call does not get triggered and your store is empty.
To solve you can add some conditional logic to your component, to do an axios call if the required data is undefined or empty.
I would like to access refs in a vue.js component, where the ref itself is created dynamically like so:
<style>
</style>
<template>
<div>
<lmap class="map" v-for="m in [1, 2, 3]" :ref="'map' + m"></lmap>
</div>
</template>
<script>
module.exports = {
components: {
lmap: httpVueLoader('components/base/map.vue'),
},
mounted: function(){
console.log('all refs', this.$refs);
// prints an object with 3 keys: map1, map2, map3
console.log('all ref keys', Object.keys(this.$refs));
// would expect ["map1", "map2", "map3"], prints an empty array instead
Vue.nextTick().then(() => {
console.log('map1', this.$refs["map1"]);
// would expect a DOM element, instead prints undefined
})
},
destroyed: function(){
},
methods: {
},
}
</script>
However this seems not to work (see above in the comments), and I can't figure why.
I think the problem is that you are importing the component asynchronously, with httpVueLoader, which then downloads and imports the component only when the component is rendered from the dom, therefore, the component has not yet been imported into the nextTick callback.
I suggest you put a loaded event in the map.vue component, maybe in mounted lifecycle , which will be listened to in the father, example #loaded = "showRefs"
surely when the showRefs(){ } method is invoked, you will have your refs populated ;)
Try using a template string e.g
`map${m}`
You have to wait until components have been rendered / updated. This works:
module.exports = {
data: function () {
return {
};
},
components: {
lmap: httpVueLoader('components/base/map.vue'),
},
mounted: function(){
},
destroyed: function(){
},
updated: function(){
Vue.nextTick().then(() => {
console.log('all ref keys', Object.keys(this.$refs));
console.log('map1', this.$refs['map1'][0].$el);
})
},
methods: {
},
}
I have several Vue components (realized as .vue files process by Webpack & the Vue Loader) that need to be synchronized (i.e. display / allow interaction of a video stream). Some of them require some initialization effort (i.e. downloading meta data or images).
Can I somehow delay a component until that initialization is ready? Perfect would be if I could wait for a Promise to be fulfilled in a lifecycle hook like beforeCreate.
I know about asynchronous components, but as far as I understand all I could do is to lazy-load that component, and I still would have a way to wait for a certain initialization.
You can do that on route level.
const router = new VueRouter({
routes: [
{
path: '/foo',
component: Foo,
beforeEnter: (to, from, next) => {
doSomeRequests()
.then(() => (next()))
}
}
]
})
doSomeRequests is a function that is doing some async code. When that async code finish
then you call next() to allow vue router to enter the component which corresponds to the url. In the example the component Foo
My suggestion would be to show a loader in the component.
<template>
<!-- code -->
<LoaderComponent :loading="loading" />
<!-- code -->
</template>
And the script part:
<script>
export default {
// code
data: () => ({
loading: false
}),
created () {
this.loading = true
doSomeRequests()
.then(<!-- code -->)
.finally(() => (this.loading = false))
}
}
</script>