Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Activated hook for directives #2349

Open
syuilo opened this issue Oct 10, 2020 · 20 comments
Open

Activated hook for directives #2349

syuilo opened this issue Oct 10, 2020 · 20 comments
Labels

Comments

@syuilo
Copy link
Contributor

syuilo commented Oct 10, 2020

What problem does this feature solve?

component's keep-alive activated and deactivated hooks should also be available in directives.

For example, suppose you have a directive that adds a class based on the size of an element. When the component to which the directive is added is in a keep-alive, the directive's mounted hook will be called, even if it is not actually mounted in the DOM, and the size of the element will be zero. If we can detect that a component with a directive bound to it is now active, we can get the correct size.

Thank you!

What does the proposed API look like?

app.directive('my-directive', {
  activated() { ... },
  deactivated() { ... },
})
@justintaddei
Copy link

This would be very helpful my v-shared-element library as well. I hope this gets implemented!

@shameleo
Copy link

shameleo commented Nov 4, 2020

My use case is v-keep-scroll directive.
In Chrome elements loose their scroll positions when detouched from DOM, so the directive fixes it. It saves scroll positions in attributes and restores it when component is activated. Can't port it to Vue 3. It used non-public 'hook:activated' event in Vue 2.

@LinusBorg
Copy link
Member

LinusBorg commented Nov 4, 2020

Note: at least for Browser environments, you should be able to use Node.isConnected (at least for some use cases) to check wether the element is in the DOM (=component is active) or not (=component is deactivatdd by keep-alive)

@shameleo
Copy link

shameleo commented Nov 5, 2020

Yep, but I need event, not just property.

@justintaddei
Copy link

Same thing here. I'm using the activated hook to tell when I need to record the position of the elements and animate from their previous state. Simply haven't a property would not work for my case.

@shameleo
Copy link

shameleo commented Feb 18, 2021

I rethinked my issue and created abstract component instead of directive. Maybe it will help somebody

Code is here
  // MIT License

  import { withDirectives, onActivated } from 'vue';

  const onScroll = ({ target }) => {
      target.dataset.scrollPos = target.scrollLeft + '-' + target.scrollTop;
  };
  const onScrollOptions = { passive: true };
  
  export default {
      setup(props, { slots }) {
          let els = [];

          const saveScroll = {
              created(el) {
                  el.addEventListener('scroll', onScroll, onScrollOptions);
                  els.push(el);
              },
              unmounted(el) {
                  el.removeEventListener('scroll', onScroll, onScrollOptions);
                  els = els.filter(e => e !== el);
              }
          };

          onActivated(() => {
              els.forEach(el => {
                  const { scrollPos } = el.dataset;

                  if (scrollPos) {
                      const pos = scrollPos.split('-');
                      el.scrollLeft = pos[0];
                      el.scrollTop = pos[1];
                  }
              });
          });

          return () => slots.default().map(vnode => withDirectives(vnode, [[ saveScroll ]]));
      }
  }

It is even more appropriate as before (using Vue 2) I used activated hook of context, and context is not a parent in general case, but simply component which template applies directive.
Consider this:

Component.vue

<template>
    ...
    <keep-alive>
        <SubComponent v-if="isHidden">
            <div v-my-directive></div>
        </SubComponent>
    </keep-alive>
    ...
</template>

vnode.context.$on('hook:activated') in directive (using Vue 2) subscribes on activated event of Component, instead of SubComponent. So if we really (?) need activated hook in directives, which component should we keep in mind?

@benavern
Copy link

Hi,
I was using the vnode.context.$on('hook:activated') too in vue 2 in a custom directive. I am migrating to vue 3 and this is not working anymore because there is no $on, $off, $once methods anymore. (but the event is still emitted!)
The abstract component solution is not fitting for me, I'd like a way to react on an event when the element is activated or deactivated. Is there something in vue instead that I can use in directives ? on maybe a native listener that will fire on Node.isConnected value change ?

@Miguelklappes
Copy link

+100 for this

@arabyalhomsi
Copy link

yes please, add this

@funkyvisions
Copy link

I was able to do this

function activated() {
    console.log("activated")
}

function deactivated() {
    console.log("deactivated")
}

const isActive = ref(false)
watch(isActive, () => {
    isActive.value ? activated() : deactivated()
})

export default {
    beforeUpdate(el, binding, vnode) {
        isActive.value = vnode.el.isConnected
    }
}

@funkyvisions
Copy link

funkyvisions commented Dec 2, 2022

Something like this can work too (works well when you have multiple instances, unlike the one above)

    beforeUpdate(el) {

        if (el.isConnected && !el.isActive) {
            el.isActive = true
            activated(el)
        }

        if (!el.isConnected && el.isActive) {
            el.isActive = false
            deactivated(el)
        }
    }

@samueleiche
Copy link

samueleiche commented May 8, 2023

Something like this can work too (works well when you have multiple instances, unlike the one above)

    beforeUpdate(el) {

        if (el.isConnected && !el.isActive) {
            el.isActive = true
            activated(el)
        }

        if (!el.isConnected && el.isActive) {
            el.isActive = false
            deactivated(el)
        }
    }

The issue with this solution is that beforeUpdate doesn't fire in all cases when the component is deactivated. so the isActive property doesn't reflect the actual state and can trigger autofocus when it's not desired.

Our team went with the composable route instead. Our issue was with the autofocus directive not able to fire when the component gets activated

import { onMounted, onActivated, Ref } from 'vue'

export function focusElement(targetInput: HTMLInputElement | null) {
	if (!targetInput) {
		return
	}

	targetInput.focus()
}

export function useAutofocus(inputRef?: Ref<HTMLInputElement | null>) {
	onMounted(() => {
		if (inputRef) {
			focusElement(inputRef.value)
		}
	})

	onActivated(() => {
		if (inputRef) {
			focusElement(inputRef.value)
		}
	})
}
setup(props) {
	const inputRef = ref<HTMLInputElement | null>(null)

	useAutofocus(inputRef)

	return {
		inputRef,
	}
},

@funkyvisions
Copy link

I reworked this today using a component from vueuse

    mounted(el) {
        const visible = useElementVisibility(el)
        watch(visible, () => {
            if (visible.value) {
                activated(el)
            } else {
                deactivated(el)
            }
        })
    }

@nolimitdev
Copy link

Hooks activated and deactivated still not supported for plugins after almost 4 years :( It really complicates plugins for custom directives v-custom-directive="...".

@tbontb-iaq
Copy link

In 2024, I found a solution.

The binding parameter contains the instance of the component, and onActivated accepts a target parameter indicating the instance where the hook needs to be injected.

So a possible implementation of a directive that saves the scroll position is as follows:

export default <Directive<HTMLElement, never, never, ScrollBehavior>>{
  mounted(el, binding) {
    let { scrollTop, scrollLeft } = el
    const instance = binding.instance?.$

    onActivated(() => {
      el.scrollTo({
        top: scrollTop,
        left: scrollLeft,
        behavior: binding.arg,
      })
    }, instance)

    onDeactivated(() => {
      scrollTop = el.scrollTop
      scrollLeft = el.scrollLeft
    }, instance)
  },
}

Works well with vue 3.5.13.

@imaverickk
Copy link

imaverickk commented Mar 17, 2025

In 2024, I found a solution.

The binding parameter contains the instance of the component, and onActivated accepts a target parameter indicating the instance where the hook needs to be injected.

So a possible implementation of a directive that saves the scroll position is as follows:

export default <Directive<HTMLElement, never, never, ScrollBehavior>>{
  mounted(el, binding) {
    let { scrollTop, scrollLeft } = el
    const instance = binding.instance?.$

    onActivated(() => {
      el.scrollTo({
        top: scrollTop,
        left: scrollLeft,
        behavior: binding.arg,
      })
    }, instance)

    onDeactivated(() => {
      scrollTop = el.scrollTop
      scrollLeft = el.scrollLeft
    }, instance)
  },
}

Works well with vue 3.5.13.

The onActivated and onDeactivated hooks will never be removed before the instance is destroyed. If the element is mounted multiple times due to v-if control, this code will lead to a memory leak.

I made some improvements.

const keepScrollElements = Symbol()
const keepScrollPosition = Symbol()

const keepScroll: Directive<HTMLElement> = {
  mounted(el, binding) {
    const instance = binding.instance?.$

    if(instance[keepScrollElements]){
      instance[keepScrollElements].add(el)
      return
    }

    instance[keepScrollElements] = new Set([el])

    onActivated(() => {
      instance[keepScrollElements].forEach((el:any)=>{
        const [left, top] = el[keepScrollPosition]
        el.scrollTo(left, top)
      })
    }, instance)

    onDeactivated(() => {
      instance[keepScrollElements].forEach((el:any)=>{
        el[keepScrollPosition] = [el.scrollLeft, el.scrollTop]
      })
    }, instance)
  },
  unmounted(el, binding){
    const instance = binding.instance?.$
    instance[keepScrollElements]?.delete(el)
  }
}

@tbontb-iaq
Copy link

The onActivated and onDeactivated hooks will never be removed before the instance is destroyed. If the element is mounted multiple times due to v-if control, this code will lead to a memory leak.

I made some improvements.

const keepScrollElements = Symbol()
const keepScrollPosition = Symbol()

const keepScroll: Directive<HTMLElement> = {
  mounted(el, binding) {
    const instance = binding.instance?.$

    if(instance[keepScrollElements]){
      instance[keepScrollElements].add(el)
      return
    }

    instance[keepScrollElements] = new Set([el])

    onActivated(() => {
      instance[keepScrollElements].forEach((el:any)=>{
        const [left, top] = el[keepScrollPosition]
        el.scrollTo(left, top)
      })
    }, instance)

    onDeactivated(() => {
      instance[keepScrollElements].forEach((el:any)=>{
        el[keepScrollPosition] = [el.scrollLeft, el.scrollTop]
      })
    }, instance)
  },
  unmounted(el, binding){
    const instance = binding.instance?.$
    instance[keepScrollElements]?.delete(el)
  }
}

Your improvements still cannot prevent memory leaks.

// SomePage.vue

<div class="app">
  <button @click="flag = !flag">switch component</button>
  <keep-alive>
    <component :is="flag ? Comp1 : Comp2" />
  </keep-alive>
</div>

// Comp1.vue
<div class="comp-1">
  <p>comp 1</p>
  <button @click="flag = !flag">switch v-if</button>
  <template v-if="flag">
    <Counter />
  </template>
  <template v-else>
    <div v-for="i of 1000" :key="i" v-keep-scroll></div>
  </template>
</div>

Comp1 will not be unmounted no matter you switch v-if or switch component, so unmounted will not be triggered. At the same time, each switch v-if will create 1000 separate divs. To eliminate the reference to the separated div, you should do the following after each update:

onUpdated(() => {
  instance[keepScrollElements]
    .values()
    .filter((el: HTMLElement) => !el.isConnected)
    .forEach((el: HTMLElement) => instance[keepScrollElements].delete(el))
}, instance)

@imaverickk
Copy link

无论你切换 v-if 还是切换组件,comp1 都不会被卸载,所以unmounted不会被触发。同时,每次切换 v-if 都会创建 1000 个独立的 div。为了消除对被分离 div 的引用,你应该在每次更新后执行以下操作:

Perhaps you have misunderstood due to an incorrect statement in the official documentation. The unmounted method in the directive will actually be executed after the element is removed, so there's no need to worry about this issue. You can verify this through the following code.

unmounted(el, binding){
  const instance = binding.instance?.$
  console.log(instance[keepScrollElements])
  instance[keepScrollElements]?.delete(el)
}

@tbontb-iaq
Copy link

<script setup>
import { ref } from 'vue'
import vUnmount from './unmount';

const flag1 = ref(true), flag2 = ref(true);
</script>

<template>
  <div class="app">
    <button @click="flag1 = !flag1">switch flag 1</button>
    <button @click="flag2 = !flag2">switch flag 2</button>

    <template v-if="flag1">
      <p v-unmount>no v-for</p>
    </template>

    <template v-if="flag2">
      <p v-for="i of 1" v-unmount>with v-for</p>
    </template>
  </div>
</template>

<style scoped>
.app {
  display: flex;
  flex-direction: column;
}

.app>* {
  margin: 1em 1em;
}
</style>

The unmounted hook of the v-unmount directive is not called on elements rendered with v-for, perhaps this is a bug. See Vue Play.

@imaverickk
Copy link

The unmounted hook of the v-unmount directive is not called on elements rendered with v-for, perhaps this is a bug. See Vue Play.

I'm sure it's a bug.

#12569

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests