Skip to content

Wrapper Vue2/3 directives to allow dynamic add/remove

A composable that converts a Vue2/3 directive in a directive that can be dynamically adder/removed.

vuedirectivecomposable
by
PDan

Problem description

When using directives in render functions in Vue3 components the beforeUnmount and unmounted lifecycle hook are not called when the directive is no longer applied.

This can lead to some unwanted leftovers that were cleaned in beforeUnmount and unmounted lifecycle hook.

Solution

The useRemovableDirective composable receives a directive definition (Vue2 or Vue3) and returns a list with two directives:

  • the first one is a patched version of the original directive
  • the second one is a cleanup directive that only calls the cleanup functions from the original directive

Then, in your render function, instead of not applying your directive in withDirectives when it is not needed you replace it with the removed verion.

vue
<script land="ts">
import {
  defineComponent,
  h,
  ref,
  withDirectives,
} from 'vue';

import useRemovableDirective from './useRemovableDirective.ts';

const logs = ref([]);

const [vColor, vColorRemoved] = useRemovableDirective({
  // called before bound element's attributes
  // or event listeners are applied
  created(el /* , binding, vnode, prevVnode */) {
    logs.value.push({ event: 'created', el: el.className, ts: (new Date()).toISOString().slice(11, 23) });
  },

  // called right before the element is inserted into the DOM.
  beforeMount(el /* , binding, vnode, prevVnode */) {
    logs.value.push({ event: 'beforeMount', el: el.className, ts: (new Date()).toISOString().slice(11, 23) });
  },

  // called when the bound element's parent component
  // and all its children are mounted.
  mounted(el, binding /* , vnode, prevVnode */) {
    logs.value.push({ event: 'mounted', el: el.className, ts: (new Date()).toISOString().slice(11, 23) });
    if (el.ctx === undefined) {
      el.ctx = { color: el.style.color };
    }
    el.style.color = binding.value;
  },

  // called before the parent component is updated
  beforeUpdate(el /* , binding, vnode, prevVnode */) {
    logs.value.push({ event: 'beforeUpdate', el: el.className, ts: (new Date()).toISOString().slice(11, 23) });
  },

  // called after the parent component and
  // all of its children have updated
  updated(el, binding /* , vnode, prevVnode */) {
    logs.value.push({ event: 'updated', el: el.className, ts: (new Date()).toISOString().slice(11, 23) });
    if (el.ctx === undefined) {
      el.ctx = { color: el.style.color };
    }
    el.style.color = binding.value;
  },

  // called before the parent component is unmounted
  beforeUnmount(el /* , binding, vnode, prevVnode */) {
    logs.value.push({ event: 'beforeUnmount', el: el.className, ts: (new Date()).toISOString().slice(11, 23) });
  },

  // called when the parent component is unmounted
  unmounted(el /* , binding, vnode, prevVnode */) {
    logs.value.push({ event: 'unmounted', el: el.className, ts: (new Date()).toISOString().slice(11, 23) });
    if (el.ctx !== undefined) {
      Object.assign(el.style, el.ctx);
    }
  },
});

const testBlock = defineComponent({
  props: {
    text: String,
    active: Boolean,
  },

  setup(props) {
    const active = ref(props.active === true);

    return () => withDirectives(
      h('div', { class: props.active === true ? 'start-applied' : 'start-not-applied' }, [
        h('label', [
          h('input', { type: 'checkbox', checked: active.value, onInput(ev) { active.value = ev.target.checked; } }),
          `Apply directive (initially ${ props.active === true ? '' : 'not ' }applied)`,
        ]),
        ` / ${ props.text.length % 2 === 0 ? 'red' : 'green' } / ${ props.text }`,
      ]),
      active.value === true
        ? [[vColor, props.text.length % 2 === 0 ? 'red' : 'green']]
        : [[vColorRemoved]],
    );
  },
});

const eventColor = (event) => {
  if (['created', 'beforeMount', 'mounted'].includes(event)) {
    return 'blue';
  }
  return ['beforeUpdate', 'updated'].includes(event)
    ? 'grey'
    : 'orangered';
};

const logsBlock = defineComponent({
  setup() {
    return () => h(
      'ol',
      logs.value.slice().reverse().map((row) => h(
        'li',
        { style: { display: 'flex' } },
        [
          h('div', { style: { width: '20ch' } }, row.ts),
          h('div', { style: { width: '20ch', color: row.el.indexOf('-not-') > -1 ? 'orangered' : 'green' } }, row.el),
          h('div', { style: { color: eventColor(row.event) } }, row.event),
        ],
      )),
    );
  },
});

export default defineComponent({
  setup() {
    const msg = ref('');

    return () => h('div', { style: 'padding: 16px' }, [
      h(testBlock, { text: msg.value, active: true }),
      h(testBlock, { text: msg.value, active: false }),

      h('input', { value: msg.value, onInput(ev) { msg.value = ev.target.value; } }),
      h(logsBlock),
    ]);
  },
});
</script>
ts
function getSSRProps() { }

export default function useRemovableDirective(dirDef) {
  const dirDefId = Symbol('id');
  const dirDefAdded = typeof dirDef === 'function'
    ? { getSSRProps, mounted: dirDef, updated: dirDef }
    : { getSSRProps, ...dirDef };
  const dirDefRemoved = { getSSRProps };

  const {
    created,
    beforeMount,
    mounted,
    beforeUpdate,
    beforeUnmount,
    unmounted,
  } = dirDefAdded;

  dirDefAdded.created = typeof created === 'function'
    ? (el, binding, vnode, prevVnode) => {
      el[dirDefId] = true;
      created(el, binding, vnode, prevVnode);
    }
    : (el) => {
      el[dirDefId] = true;
    };

  if (
    typeof created === 'function'
    || typeof beforeMount === 'function'
    || typeof mounted === 'function'
  ) {
    const calls = [];
    if (typeof created === 'function') { calls.push(created); }
    if (typeof beforeMount === 'function') { calls.push(beforeMount); }
    if (typeof mounted === 'function') { calls.push(mounted); }

    dirDefAdded.beforeUpdate = typeof beforeUpdate === 'function'
      ? (el, binding, vnode, prevVnode) => {
        if (el[dirDefId] !== true) {
          el[dirDefId] = true;
          calls.forEach((call) => { call(el, binding, vnode, prevVnode); });
        }

        beforeUpdate(el, binding, vnode, prevVnode);
      }
      : (el, binding, vnode, prevVnode) => {
        if (el[dirDefId] !== true) {
          el[dirDefId] = true;
          calls.forEach((call) => { call(el, binding, vnode, prevVnode); });
        }
      };
  }

  if (typeof beforeUnmount === 'function') {
    dirDefRemoved.beforeUpdate = (el, binding, vnode, prevVnode) => {
      if (el[dirDefId] === true) {
        beforeUnmount(el, binding, vnode, prevVnode);
      }
    };
  }

  dirDefRemoved.updated = typeof unmounted === 'function'
    ? (el, binding, vnode, prevVnode) => {
      if (el[dirDefId] === true) {
        unmounted(el, binding, vnode, prevVnode);
        el[dirDefId] = false;
      }
    }
    : (el) => {
      if (el[dirDefId] === true) {
        el[dirDefId] = false;
      }
    };

  return [dirDefAdded, dirDefRemoved];
}

Demo

Last updated: