Vue component inside from html field

Has anyone successfully implemented some Vue components inside a form html field ?
I’m particularly interested in what Form events do you hook on, and how to handle destroying the Vue instances created.
Do you create a new Vue instance on each form refresh() ?

There was this thread a while back, but we didn’t get too deep into things like memory management (though it’s probably important):

Thanks peterg but this creates a Vue instance on each form refresh without destroying previous one.
It creates a memory leak and will causes many issues when your components listen to outside events, as all instances will react.

Is that true? I remember looking into the question a bit back at the time, and my impression then was that there wasn’t a memory leak. I thought that Vue handles instance lifecycle events automatically when the DOM objects are destroyed, but it is extremely possible that my understanding was incorrect. I didn’t look into it very deeply even then, and my memory is a bit hazy. I’d be very interested to learn more if you figure anything out.

In any case, that’s definitely the kind of thing that needs to be investigated before moving beyond proof of concept. If the instances aren’t being destroyed, it should be possible to reuse the original instance on refresh.

Me too I was hoping the Vue instance would be automatically destroyed when the DOM objects are destroyed, but it isn’t happening in my testing. We have to manually call the $destroy() on the instance.

I noticed there is an __vue__ property added to the html element, I will play with it and try to call this.element.__vue__.$destroy() before mounting new instance.

There is another thing that bothers me when re-creating instances; we can see the component visual being removed and replaced. It is particularly noticeable when rendering a table with many rows. This makes a less ‘polished’ experience if I compare to a simple jquery html replace.

What I would prefer is to create a Vue instance only once (per doc), and then call a refresh method on the component. However I could not find any reliable hook event to create the instance when the form is shown. More details here: Form event on open

Hmm…interesting. I’ve just played around a bit and can confirm that it’s not getting destroyed, and more strangely the DOM objects themselves aren’t getting destroyed even when moving to another route.

My hunch is that the best solution would be to stick with the refresh method for vue instance management, but rather than automatically creating first see if the Vue object needs to be created and react accordingly. I’ll keep poking around, and please let me know if you find anything.

Here’s a quick stab at some code that seems to handle memory management better, but I haven’t had a chance to test it much yet (note the use of both onload and refresh events, the first for html manipulation and the second for state management)

frappe.ui.form.on('Test Doctype', {
    setup: frm => {
        console.log("loading")
        $(frm.fields_dict.ditto.wrapper).html(`
        <table id="cat-facts" class="table table-bordered">
            <caption style="caption-side: top;">Cat Facts</caption>
            <thead>
                <tr class="d-flex">
                    <th class="col-3">ID</th>
                    <th class="col-2">Date Created</th>
                    <th class="col-7">Fact</th>
                </tr>
            </thead>
            <tbody>
                <tr v-if="facts_loading">
                    <td style="text-align: center">
                        <div class="spinner-border spinner-border-sm text-secondary" role="status">
                            <span class="sr-only">Loading...</span>
                        </div>
                    </td>
                </tr>
                <tr v-cloak class="d-flex" v-for="(fact, index) in cat_facts">
                    <td class="col-3">{{ fact._id }}</td>
                    <td class="col-2">{{ fact.createdAt.slice(0,10) }}</td>
                    <td class="col-7">{{ fact.text }}</td>
                </tr>
                <tr v-cloak v-if="cat_facts.length === 0 && !facts_loading">
                    <td style="font-style: italic">No data.</td>
                </tr>
            </tbody>
        </table>
        `);
    },
    refresh: frm => {
        console.log("refreshing")
        if(typeof frm.vm === 'undefined') {
        	frm.vm = new Vue({ 
                el: "#cat-facts", 
                data: {
                    facts_loading: true,
                    cat_facts: []
                }
            });
        }
        frm.vm.facts_loading = true;
        frm.vm.cat_facts = []
        $.get("https://cat-fact.herokuapp.com/facts/random?animal_type=cat&amount=5", (data) => {
            frm.vm.facts_loading = false;
            frm.vm.cat_facts = data;
        })
    }
});

EDIT: fixed event hook onloadsetup.

1 Like

Try creating 2 ‘Test Doctype’ records and navigating between the 2 records, you will see it breaks the component because onload overwrite dom element, but frm.vm remains mounted on the old element.

I’m working on another sample, I’ll keep you posted.

Here is an updated version.

Key points:

  • Show docname in the vue template to ensure we are binded to the right doc
  • Create a single Vue instance in setup event. At this moment it is empty, we don’t bind data yet.
  • In refresh event, set data in frm.vm
  • IMPORTANT - in the doctype definition, docfield html_wrapper must absolutely have nothing set in Options section. If some html is set there, then frappe will overwrite our dom element and mess everything.

One remaining annoyance is when we navigate doc-1, doc-2, doc-1,… we can see the previous doc data for a sub-second. I’ve yet to find a event where I could set empty data on form-open.

frappe.ui.form.on('Test Doctype', {
	setup: frm => {
		console.log("setup")
		$(frm.fields_dict.html_wrapper.wrapper).html(/*html*/`
			<div class="cat-facts">
				<h1>{{title}}</h1>
				<table  class="table table-bordered">
					<caption style="caption-side: top;">Cat Facts</caption>
					<thead>
						<tr class="d-flex">
							<th class="col-3">ID</th>
							<th class="col-2">Date Created</th>
							<th class="col-7">Fact</th>
						</tr>
					</thead>
					<tbody>
						<tr v-if="facts_loading">
							<td style="text-align: center">
								<div class="spinner-border spinner-border-sm text-secondary" role="status">
									<span class="sr-only">Loading...</span>
								</div>
							</td>
						</tr>
						<tr v-cloak class="d-flex" v-for="(fact, index) in cat_facts">
							<td class="col-3">{{ fact._id }}</td>
							<td class="col-2">{{ fact.createdAt.slice(0,10) }}</td>
							<td class="col-7">{{ fact.text }}</td>
						</tr>
						<tr v-cloak v-if="cat_facts.length === 0 && !facts_loading">
							<td style="font-style: italic">No data.</td>
						</tr>
					</tbody>
				</table>
		  	</div>
		`);
		frm.vm = new Vue({ 
			el: $(frm.fields_dict.html_wrapper.wrapper).find(".cat-facts")[0], 
			data: {
				facts_loading: true,
				cat_facts: [],
				title: ""
			}
		});
	},
	onload: frm => {
	},
	refresh: frm => {
		console.log("refreshing")
		frm.vm.facts_loading = true;
		$.get("https://cat-fact.herokuapp.com/facts/random?animal_type=cat&amount=5", (data) => {
			frm.vm.facts_loading = false;
			frm.vm.cat_facts = data;
			frm.vm.title = frm.doc.name;
		})
	  }
  });
1 Like

Shoot, you’re right. I mixed up ‘onload’ and ‘setup’ in my previous post. Just changing that one keyword, it seems to work correctly now.

I think I still prefer creating the vue instances conditionally in ‘refresh’, but that might just be a stylistic preference. Is there any functional difference you can notice? Also, by clearing the data at the start of the refresh method, I don’t get a flicker.

When I do this, every time I hit save in the doc form (or any frm.refresh() call) I can see the table removed and re-added, creating a scroll flicker. That is why I was looking for a form-open event, so I can clear data only then.

Indeed, that’d be a nice event hook. In the meantime, wouldn’t it be fairly straightforward to do on refresh using a value attached to frm?

Something like…

refresh(frm) {
	if (frm.last_time != frm.doc.name) {
		console.log("new form")
	}
	frm.last_time = frm.doc.name
}
2 Likes