Pulled ALL skills from 15 source repositories: - anthropics/skills: 16 (docs, design, MCP, testing) - obra/superpowers: 14 (TDD, debugging, agents, planning) - coreyhaines31/marketingskills: 25 (marketing, CRO, SEO, growth) - better-auth/skills: 5 (auth patterns) - vercel-labs/agent-skills: 5 (React, design, Vercel) - antfu/skills: 16 (Vue, Vite, Vitest, pnpm, Turborepo) - Plus 13 individual skills from various repos Mosaic Stack is not limited to coding — the Orchestrator and subagents serve coding, business, design, marketing, writing, logistics, analysis, and more. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
220 lines
5.6 KiB
Markdown
220 lines
5.6 KiB
Markdown
---
|
|
title: Clean Up Side Effects in Directive unmounted Hook
|
|
impact: HIGH
|
|
impactDescription: Failing to clean up intervals, event listeners, and subscriptions in directives causes memory leaks
|
|
type: gotcha
|
|
tags: [vue3, directives, memory-leak, cleanup, unmounted, event-listeners]
|
|
---
|
|
|
|
# Clean Up Side Effects in Directive unmounted Hook
|
|
|
|
**Impact: HIGH** - A common and critical mistake when creating custom directives is forgetting to clean up intervals, event listeners, and other side effects in the `unmounted` hook. This causes memory leaks and ghost handlers that continue running after the element is removed from the DOM.
|
|
|
|
The key to avoiding such bugs is always implementing the `unmounted` hook to clean up any resources created in `mounted` or other lifecycle hooks.
|
|
|
|
## Task Checklist
|
|
|
|
- [ ] Always pair resource creation in `mounted` with cleanup in `unmounted`
|
|
- [ ] Store references to intervals, timeouts, and listeners for later cleanup
|
|
- [ ] Use `el.dataset` or WeakMap to share data between directive hooks
|
|
- [ ] Test that directives properly clean up when elements are removed (v-if toggling)
|
|
|
|
**Incorrect:**
|
|
```javascript
|
|
// WRONG: No cleanup - memory leak!
|
|
const vPoll = {
|
|
mounted(el, binding) {
|
|
// This interval runs forever, even after element is removed!
|
|
setInterval(() => {
|
|
console.log('polling...')
|
|
binding.value?.()
|
|
}, 1000)
|
|
}
|
|
}
|
|
|
|
// WRONG: Event listener persists after unmount
|
|
const vClickOutside = {
|
|
mounted(el, binding) {
|
|
document.addEventListener('click', (e) => {
|
|
if (!el.contains(e.target)) {
|
|
binding.value()
|
|
}
|
|
})
|
|
// No cleanup - listener stays attached to document!
|
|
}
|
|
}
|
|
```
|
|
|
|
**Correct:**
|
|
```javascript
|
|
// CORRECT: Store reference and clean up
|
|
const vPoll = {
|
|
mounted(el, binding) {
|
|
// Store interval ID on the element for later cleanup
|
|
el._pollInterval = setInterval(() => {
|
|
console.log('polling...')
|
|
binding.value?.()
|
|
}, 1000)
|
|
},
|
|
unmounted(el) {
|
|
// Clean up the interval
|
|
if (el._pollInterval) {
|
|
clearInterval(el._pollInterval)
|
|
delete el._pollInterval
|
|
}
|
|
}
|
|
}
|
|
|
|
// CORRECT: Named function for proper removal
|
|
const vClickOutside = {
|
|
mounted(el, binding) {
|
|
el._clickOutsideHandler = (e) => {
|
|
if (!el.contains(e.target)) {
|
|
binding.value()
|
|
}
|
|
}
|
|
document.addEventListener('click', el._clickOutsideHandler)
|
|
},
|
|
unmounted(el) {
|
|
if (el._clickOutsideHandler) {
|
|
document.removeEventListener('click', el._clickOutsideHandler)
|
|
delete el._clickOutsideHandler
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## Using WeakMap for Cleaner State Management
|
|
|
|
```javascript
|
|
// BEST: Use WeakMap to avoid polluting element properties
|
|
const pollIntervals = new WeakMap()
|
|
const clickHandlers = new WeakMap()
|
|
|
|
const vPoll = {
|
|
mounted(el, binding) {
|
|
const intervalId = setInterval(() => {
|
|
binding.value?.()
|
|
}, binding.arg || 1000)
|
|
pollIntervals.set(el, intervalId)
|
|
},
|
|
unmounted(el) {
|
|
const intervalId = pollIntervals.get(el)
|
|
if (intervalId) {
|
|
clearInterval(intervalId)
|
|
pollIntervals.delete(el)
|
|
}
|
|
}
|
|
}
|
|
|
|
const vClickOutside = {
|
|
mounted(el, binding) {
|
|
const handler = (e) => {
|
|
if (!el.contains(e.target)) {
|
|
binding.value()
|
|
}
|
|
}
|
|
clickHandlers.set(el, handler)
|
|
document.addEventListener('click', handler)
|
|
},
|
|
unmounted(el) {
|
|
const handler = clickHandlers.get(el)
|
|
if (handler) {
|
|
document.removeEventListener('click', handler)
|
|
clickHandlers.delete(el)
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## Complete Example with Multiple Resources
|
|
|
|
```javascript
|
|
const vAutoScroll = {
|
|
mounted(el, binding) {
|
|
const state = {
|
|
intervalId: null,
|
|
resizeObserver: null,
|
|
scrollHandler: null
|
|
}
|
|
|
|
// Set up polling
|
|
state.intervalId = setInterval(() => {
|
|
el.scrollTop = el.scrollHeight
|
|
}, binding.value?.interval || 100)
|
|
|
|
// Set up resize observer
|
|
state.resizeObserver = new ResizeObserver(() => {
|
|
el.scrollTop = el.scrollHeight
|
|
})
|
|
state.resizeObserver.observe(el)
|
|
|
|
// Set up scroll listener
|
|
state.scrollHandler = () => {
|
|
// Track user scroll
|
|
}
|
|
el.addEventListener('scroll', state.scrollHandler)
|
|
|
|
// Store all state for cleanup
|
|
el._autoScrollState = state
|
|
},
|
|
|
|
unmounted(el) {
|
|
const state = el._autoScrollState
|
|
if (!state) return
|
|
|
|
// Clean up everything
|
|
if (state.intervalId) {
|
|
clearInterval(state.intervalId)
|
|
}
|
|
if (state.resizeObserver) {
|
|
state.resizeObserver.disconnect()
|
|
}
|
|
if (state.scrollHandler) {
|
|
el.removeEventListener('scroll', state.scrollHandler)
|
|
}
|
|
|
|
delete el._autoScrollState
|
|
}
|
|
}
|
|
```
|
|
|
|
## Testing Directive Cleanup
|
|
|
|
```javascript
|
|
// Test that cleanup works properly
|
|
import { mount } from '@vue/test-utils'
|
|
import { ref, nextTick } from 'vue'
|
|
|
|
const vTrackInterval = {
|
|
mounted(el) {
|
|
el._interval = setInterval(() => {}, 100)
|
|
window.__activeIntervals = (window.__activeIntervals || 0) + 1
|
|
},
|
|
unmounted(el) {
|
|
clearInterval(el._interval)
|
|
window.__activeIntervals--
|
|
}
|
|
}
|
|
|
|
test('directive cleans up interval on unmount', async () => {
|
|
const show = ref(true)
|
|
const wrapper = mount({
|
|
template: `<div v-if="show" v-track-interval></div>`,
|
|
directives: { 'track-interval': vTrackInterval },
|
|
setup: () => ({ show })
|
|
})
|
|
|
|
expect(window.__activeIntervals).toBe(1)
|
|
|
|
show.value = false
|
|
await nextTick()
|
|
|
|
expect(window.__activeIntervals).toBe(0)
|
|
})
|
|
```
|
|
|
|
## Reference
|
|
- [Vue.js Custom Directives - Directive Hooks](https://vuejs.org/guide/reusability/custom-directives#directive-hooks)
|
|
- [Vue School - The Directive's unmounted Hook](https://vueschool.io/lessons/vue-3-the-directive-s-unmounted-hook)
|