unlock unlimited code runs for $79, limited time →
NOSSLETTER.techLIVE
back to OSSpedia
vuejs/core
senior9/10
memory-managementerror-handlingtesting-strategy

fix(runtime-vapor): dispose component v-for item resources on removal

Project: vuejs/core | Language: TypeScript | Concepts: memory-management, error-handling, testing-strategy | Difficulty: senior
Link: https://github.com/vuejs/core/pull/15283

The problem

When using v-for to render a list of components in Vue's Vapor mode, removing an item from the list did not properly clean up resources (such as effects, DOM nodes, and instance references) associated with the removed component. This caused memory leaks: every add/remove cycle would accumulate closures and DOM nodes in the parent scope, leading to steadily increasing retained memory.

Why it's non-trivial

A naive fix might simply try to stop or clean up component resources when an item is removed. However, the original implementation skipped creating a per-item effect scope for component v-for items, assuming the component instance's own scope was sufficient. This led to unmount callbacks being registered on the parent scope, which were never cleaned up when the component was removed. Fixing this requires careful coordination between per-item scopes, component instance lifecycles, and the order of teardown (e.g., DOM removal before scope stopping).

The approach

1. Always create a per-item effect scope, even for components

Previously, the code only created an effect scope for non-component items. The fix changes this so that every v-for item, including components, gets its own effect scope:

-  if (!isComponent) {
-    onScopeDispose(() => {
-      stopBlockScopes(oldBlocks)
-      if (newBlocks && newBlocks !== oldBlocks) {
-        stopBlockScopes(newBlocks)
-      }
-      oldBlocks = []
-      newBlocks = []
-    }, true)
-  }
+  onScopeDispose(() => {
+    stopBlockScopes(oldBlocks)
+    if (newBlocks && newBlocks !== oldBlocks) {
+      stopBlockScopes(newBlocks)
+    }
+    oldBlocks = []
+    newBlocks = []
+  }, true)

Now, all blocks (component or not) are cleaned up when the parent scope is disposed.

2. Each v-for item is rendered inside its own effect scope

-    let nodes: Block
-    let scope: EffectScope | undefined
-    if (isComponent) {
-      // component already has its own scope so no outer scope needed
-      nodes = renderItem(itemRef, keyRef as any, indexRef as any)
-    } else {
-      scope = new EffectScope(true)
-      try {
-        nodes = scope.run(() =>
-          renderItem(itemRef, keyRef as any, indexRef as any),
-        )!
-      } catch (err) {
-        scope.stop()
-        throw err
-      }
-    }
+    const scope = new EffectScope(true)
+    try {
+      nodes = scope.run(() =>
+        renderItem(itemRef, keyRef as any, indexRef as any),
+      )!
+    } catch (err) {
+      scope.stop()
+      throw err
+    }

This ensures all resources created for an item (including component instances) are tracked by the item's scope and can be disposed together.

3. Teardown order: remove DOM before stopping component scope

When removing a component item, the DOM is now removed before the scope is stopped, ensuring that cleanup callbacks (like refs) see the DOM as already disconnected:

+    if (isComponent) {
+      // Component item cleanups such as template refs must observe the
+      // component after structural removal.
+      block.scope!.stop()
+    }

4. Improved comments and flag meaning

The meaning of the IS_COMPONENT flag is clarified: it now indicates the need for proper teardown ordering, not that the item is always a component instance.

-   * v-for used on component - we can skip creating child scopes for each block
-   * because the component itself already has a scope. This does not guarantee
-   * the item block is a VaporComponentInstance: component fallback paths may
-   * still return a DOM Node.
+   * v-for used on a component-shaped item. Component items require structural
+   * removal before item scope cleanup and cannot use the fast-clear path. This
+   * does not guarantee the item block is a VaporComponentInstance: component
+   * fallback paths may still return a DOM Node.

5. Extensive regression tests

Multiple new tests were added to verify that:

  • Owner scope cleanup length and effect count remain stable after repeated add/remove cycles.
  • Unmount callbacks are called the correct number of times.
  • Dynamic component effects and v-show effects are properly cleaned up.
  • DOM nodes are disconnected before cleanup callbacks run.
  • No prop source cache is retained for native fallback components.

Example from the tests:

test('component item resources do not accumulate in the owner scope', async () => {
  // ... setup ...
  state.value.items = [0, 1, 2]
  await nextTick()
  expectStableResources()

  state.value.items.splice(1, 1)
  await nextTick()
  expect(unmounted).toHaveBeenCalledOnce()
  expectStableResources()
  // ... more checks ...
})

From the review thread

No notable review discussion on this PR.

Takeaways

  • Always ensure resource ownership and cleanup are localized: Each dynamic item (including components) should have its own effect scope so that resources are not leaked when the item is removed.
  • Order matters in teardown: When cleaning up, remove DOM nodes before stopping scopes to ensure cleanup callbacks see the correct state.
  • Regression tests are essential for lifecycle and memory management: Tests should verify not only functional correctness but also resource stability across repeated mount/unmount cycles.