fix(runtime-vapor): clean up teleport targets on scope disposal
Project: vuejs/core | Language: TypeScript | Concepts: memory-management, error-handling, testing-strategy | Difficulty: mid
Link: https://github.com/vuejs/core/pull/15238
The problem
In the Vue Vapor renderer, teleported content (elements rendered into a different part of the DOM) was not always properly cleaned up when its owning component or slot was removed, especially in dynamic scenarios like keyed v-for slots. This could leave orphaned DOM nodes or unmounted modals in the document, leading to memory leaks and inconsistent UI.
Why it's non-trivial
A naive fix might just remove the teleport's DOM nodes when the parent component is unmounted. However, teleports can be dynamically created and destroyed, deferred, or disabled, and may be owned by different scopes (including those created by slot v-for loops). The cleanup logic must handle all these cases, ensuring that teleport targets are disposed of at the right time and in the right way, without interfering with transitions or leaving behind DOM artifacts.
The approach
1. Registering teleport cleanup with the correct scope
Previously, teleport cleanup was only registered with the scope if mounted via insertion state. Now, the code ensures that teleport target cleanup is always registered, regardless of how the teleport was mounted:
- onScopeDispose(() => frag.dispose(), true)
+ onScopeDispose(() => frag.disposeTarget(), true)
+ } else {
+ // Give normal block removal (and Transition leave preparation) the
+ // current stack before falling back to target-side cleanup.
+ onScopeDispose(() => frag.scheduleTargetDispose(), true)
Annotation:
- If the teleport was mounted via insertion,
disposeTarget()is called directly on scope disposal. - Otherwise,
scheduleTargetDispose()is used, which defers the cleanup to the next tick, allowing transitions or other cleanup logic to run first.
2. Splitting and improving disposal logic in TeleportFragment
The TeleportFragment class now has more granular disposal methods to handle various mounting states:
+ private cancelMountToTarget(): void {
+ if (this.mountToTargetJob) {
+ this.mountToTargetJob.flags! |= SchedulerJobFlags.DISPOSED
+ this.mountToTargetJob = undefined
+ }
+ }
+
+ disposeTarget(): void {
+ this.cancelMountToTarget()
+ const mountState = this.mountState
+ if (this.nodes && mountState.location === TeleportMountLocation.Target) {
+ const targetParent =
+ (this.targetStart && parentNode(this.targetStart)) ||
+ (this.targetAnchor && parentNode(this.targetAnchor)) ||
+ undefined
+ remove(this.nodes, targetParent)
+ this.nodes = []
+ this.mountState = { location: TeleportMountLocation.None }
+ }
+ // ...remove anchors and clean up...
+ }
+
+ scheduleTargetDispose(): void {
+ this.cancelMountToTarget()
+ queuePostFlushCb(() => this.disposeTarget())
+ }
Annotation:
cancelMountToTarget()aborts any pending mount jobs.disposeTarget()removes teleported nodes specifically from the target container.scheduleTargetDispose()schedules this cleanup for the next tick, to avoid interfering with transitions.
3. Comprehensive regression tests
New and improved tests cover dynamic slot removal, transition edge cases, and disabled teleports:
+ test('should remove teleported content when a dynamic v-for slot is removed', async () => {
+ // ...setup...
+ cats.value = ['b']
+ await nextTick()
+ expect(unmounted).toHaveBeenCalledOnce()
+ expect(document.body.querySelectorAll('#teleported')).toHaveLength(0)
+ })
Annotation:
- This test ensures that when a slot (and its teleport) is removed from a dynamic v-for, the teleported DOM node is also removed and all lifecycle hooks are called.
+test('should dispose target after v-for fast remove clears it', async () => {
+ // ...setup...
+ items.value = []
+ await nextTick()
+ expect(
+ root.querySelector('#teleport-fast-remove-target')!.textContent,
+ ).toBe('')
+})
Annotation:
- This test checks that when a teleport is removed rapidly via a v-for update, its target is also cleaned up.
From the review thread
No notable review discussion on this PR.
Takeaways
- Always register resource cleanup (like DOM node removal) with the owning scope, even for dynamically generated or inserted elements.
- Defer destructive cleanup actions (such as DOM removal) when transitions or other asynchronous logic may still need access to the nodes.
- Comprehensive regression tests for edge cases (like dynamic slots and transitions) are essential for robust memory management in UI frameworks.