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>
150 lines
4.2 KiB
Markdown
150 lines
4.2 KiB
Markdown
---
|
|
title: Unmount Hooks May Not Fire Inside Transitions During Fast Replacement
|
|
impact: MEDIUM
|
|
impactDescription: Components inside transitions can be destroyed without unmount hooks firing under race conditions
|
|
type: gotcha
|
|
tags: [vue3, lifecycle, transition, onUnmounted, unmounted, cleanup, race-condition]
|
|
---
|
|
|
|
# Unmount Hooks May Not Fire Inside Transitions During Fast Replacement
|
|
|
|
**Impact: MEDIUM** - When a component inside a `<transition>` is replaced by another component during the transition's loading phase, the unmount hooks (`onBeforeUnmount`, `onUnmounted`) may not be called even though the component is removed from the DOM. This can cause memory leaks and resource leaks from unclean side effects.
|
|
|
|
This is a known edge case that occurs when the timing is specific - if a parent component with a child inside a transition is replaced while the child is still mounting. The child's mount hooks fire, but unmount hooks never do.
|
|
|
|
## Task Checklist
|
|
|
|
- [ ] Be aware that unmount hooks are not 100% guaranteed inside transitions
|
|
- [ ] For critical cleanup, consider alternative cleanup strategies
|
|
- [ ] Use `mode="out-in"` on transitions to ensure old component fully unmounts before new mounts
|
|
- [ ] For essential resources, consider cleanup at parent component level
|
|
- [ ] Test component replacement scenarios during development
|
|
|
|
**Problematic Scenario:**
|
|
```vue
|
|
<!-- Parent component with lazy-loaded child in transition -->
|
|
<template>
|
|
<transition>
|
|
<Suspense>
|
|
<component :is="currentComponent" />
|
|
</Suspense>
|
|
</transition>
|
|
</template>
|
|
```
|
|
|
|
```javascript
|
|
// Child component - unmount hooks may not fire if parent changes quickly
|
|
export default {
|
|
setup() {
|
|
const socket = new WebSocket('wss://example.com')
|
|
|
|
onMounted(() => {
|
|
console.log('Mounted - this will run')
|
|
socket.connect()
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
// WARNING: This may NOT run if component is inside transition
|
|
// and parent navigates away during mounting phase!
|
|
console.log('Unmounted - might not run')
|
|
socket.close()
|
|
})
|
|
}
|
|
}
|
|
```
|
|
|
|
**Safer Patterns:**
|
|
```vue
|
|
<!-- SAFER: Use out-in mode to ensure proper sequencing -->
|
|
<template>
|
|
<transition mode="out-in">
|
|
<component :is="currentComponent" :key="currentKey" />
|
|
</transition>
|
|
</template>
|
|
```
|
|
|
|
```javascript
|
|
// SAFER: Cleanup at parent level for critical resources
|
|
// Parent component
|
|
export default {
|
|
setup() {
|
|
const childSocket = ref(null)
|
|
|
|
// Parent controls resource lifecycle
|
|
provide('registerSocket', (socket) => {
|
|
childSocket.value = socket
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
// Parent ensures cleanup even if child unmount hook doesn't fire
|
|
childSocket.value?.close()
|
|
})
|
|
}
|
|
}
|
|
|
|
// Child component
|
|
export default {
|
|
setup() {
|
|
const registerSocket = inject('registerSocket')
|
|
const socket = new WebSocket('wss://example.com')
|
|
|
|
// Register with parent for backup cleanup
|
|
registerSocket(socket)
|
|
|
|
onMounted(() => {
|
|
socket.connect()
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
socket.close() // Still attempt cleanup here
|
|
})
|
|
}
|
|
}
|
|
```
|
|
|
|
```javascript
|
|
// SAFER: Use AbortController pattern for cancellable operations
|
|
export default {
|
|
setup() {
|
|
const abortController = new AbortController()
|
|
|
|
onMounted(() => {
|
|
fetch('/api/data', { signal: abortController.signal })
|
|
.then(handleData)
|
|
.catch(err => {
|
|
if (err.name !== 'AbortError') {
|
|
handleError(err)
|
|
}
|
|
})
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
// If this doesn't fire, request continues but response is ignored
|
|
// Not a memory leak - just potentially wasted network call
|
|
abortController.abort()
|
|
})
|
|
}
|
|
}
|
|
```
|
|
|
|
## Testing for This Issue
|
|
|
|
```javascript
|
|
// Test by rapidly switching components during async loading
|
|
async function testUnmountHooks() {
|
|
// Mount component A (has async setup)
|
|
await mountComponent('A')
|
|
|
|
// Immediately switch to B before A finishes mounting
|
|
await mountComponent('B')
|
|
|
|
// Check if A's unmount hooks fired
|
|
// They may not have!
|
|
}
|
|
```
|
|
|
|
## Reference
|
|
- [Vue.js GitHub Issue #6260](https://github.com/vuejs/core/issues/6260)
|
|
- [Vue.js Transition](https://vuejs.org/guide/built-ins/transition.html)
|
|
- [Vue.js Lifecycle Hooks](https://vuejs.org/guide/essentials/lifecycle.html)
|