Explorar el Código

fix(runtime-vapor): properly unmount interop VDOM components

daiwei hace 1 mes
padre
commit
c75d471bf6

+ 149 - 0
packages/runtime-vapor/__tests__/vdomInterop.spec.ts

@@ -1505,6 +1505,40 @@ describe('vdomInterop', () => {
       expect(vdomRef.value.name).toBe('vdomChild')
     })
 
+    it('dynamic component includes vdom component should unmount with vapor branch', async () => {
+      const show = ref(true)
+      const unmounted = vi.fn()
+      const VdomChild = defineComponent({
+        setup() {
+          onUnmounted(unmounted)
+          return () => h('div', 'vdom child')
+        },
+      })
+
+      const VaporChild = defineVaporComponent({
+        setup() {
+          return createIf(
+            () => show.value,
+            () => createDynamicComponent(() => VdomChild),
+          )
+        },
+      })
+
+      const { html } = define({
+        setup() {
+          return () => h(VaporChild as any)
+        },
+      }).render()
+
+      expect(html()).toContain('<div>vdom child</div>')
+
+      show.value = false
+      await nextTick()
+
+      expect(unmounted).toHaveBeenCalledTimes(1)
+      expect(html()).not.toContain('vdom child')
+    })
+
     it('dynamic component includes vdom component should cleanup old ref', async () => {
       const VdomChild = defineComponent({
         setup(_, { expose }) {
@@ -1906,6 +1940,121 @@ describe('vdomInterop', () => {
           expect(hooksA.activated).toHaveBeenCalledTimes(2)
         })
 
+        it('unmounts cached inner VDOM components', async () => {
+          const hooksA = {
+            unmounted: vi.fn(),
+          }
+          const hooksB = {
+            unmounted: vi.fn(),
+          }
+
+          const VDOMCompA = defineComponent({
+            setup() {
+              onUnmounted(() => hooksA.unmounted())
+              return () => h('div', 'vdom A')
+            },
+          })
+
+          const VDOMCompB = defineComponent({
+            setup() {
+              onUnmounted(() => hooksB.unmounted())
+              return () => h('div', 'vdom B')
+            },
+          })
+
+          const current = shallowRef<any>(VDOMCompA)
+
+          const App = defineVaporComponent({
+            setup() {
+              return createComponent(VaporKeepAlive, null, {
+                default: () => createDynamicComponent(() => current.value),
+              })
+            },
+          })
+
+          const root = document.createElement('div')
+          const app = createApp({
+            setup() {
+              return () => h(App as any)
+            },
+          })
+          app.use(vaporInteropPlugin)
+          app.mount(root)
+
+          expect(root.innerHTML).toBe(
+            '<div>vdom A</div><!--dynamic-component-->',
+          )
+
+          current.value = VDOMCompB
+          await nextTick()
+          expect(root.innerHTML).toBe(
+            '<div>vdom B</div><!--dynamic-component-->',
+          )
+          expect(hooksA.unmounted).toHaveBeenCalledTimes(0)
+          expect(hooksB.unmounted).toHaveBeenCalledTimes(0)
+
+          app.unmount()
+          await nextTick()
+          expect(hooksA.unmounted).toHaveBeenCalledTimes(1)
+          expect(hooksB.unmounted).toHaveBeenCalledTimes(1)
+        })
+
+        it('unmounts inactive cached inner VDOM components during KeepAlive hmr rerender', async () => {
+          const hooksA = {
+            unmounted: vi.fn(),
+          }
+          const hooksB = {
+            unmounted: vi.fn(),
+          }
+
+          const VDOMCompA = defineComponent({
+            setup() {
+              onUnmounted(() => hooksA.unmounted())
+              return () => h('div', 'vdom A')
+            },
+          })
+
+          const VDOMCompB = defineComponent({
+            setup() {
+              onUnmounted(() => hooksB.unmounted())
+              return () => h('div', 'vdom B')
+            },
+          })
+
+          const current = shallowRef<any>(VDOMCompA)
+          let keepAlive: any
+
+          const App = defineVaporComponent({
+            setup() {
+              keepAlive = createComponent(VaporKeepAlive, null, {
+                default: () => createDynamicComponent(() => current.value),
+              })
+              return keepAlive
+            },
+          })
+
+          const { html } = define({
+            setup() {
+              return () => h(App as any)
+            },
+          }).render()
+
+          expect(html()).toBe('<div>vdom A</div><!--dynamic-component-->')
+
+          current.value = VDOMCompB
+          await nextTick()
+          expect(html()).toBe('<div>vdom B</div><!--dynamic-component-->')
+          expect(hooksA.unmounted).toHaveBeenCalledTimes(0)
+          expect(hooksB.unmounted).toHaveBeenCalledTimes(0)
+
+          keepAlive.hmrRerender()
+          await nextTick()
+
+          expect(hooksA.unmounted).toHaveBeenCalledTimes(1)
+          expect(hooksB.unmounted).toHaveBeenCalledTimes(1)
+          expect(html()).toBe('<div>vdom B</div><!--dynamic-component-->')
+        })
+
         it('switch VNode with inner mixed vapor/VDOM components', async () => {
           const hooksA = {
             mounted: vi.fn(),

+ 9 - 1
packages/runtime-vapor/src/components/KeepAlive.ts

@@ -125,7 +125,15 @@ const VaporKeepAliveImpl = defineVaporComponent({
       const rerender = keepAliveInstance.hmrRerender
       keepAliveInstance.hmrRerender = () => {
         keepAliveInstance.exposed = null
-        cache.forEach(cached => unsetShapeFlag(cached))
+        cache.forEach(cached => {
+          unsetShapeFlag(cached)
+          if (cached !== current) {
+            // Cached blocks may contain interop children whose VDOM teardown
+            // is owned by remove(), not scope.stop().
+            const parentNode = findBlockNode(cached).parentNode
+            if (parentNode) remove(cached, parentNode as ParentNode)
+          }
+        })
         cache.clear()
         keys.clear()
         keptAliveScopes.forEach(scope => scope.stop())

+ 3 - 6
packages/runtime-vapor/src/vdomInterop.ts

@@ -919,7 +919,7 @@ function createVDOMComponent(
   }
   const unmount = (parentNode?: ParentNode, transition?: TransitionHooks) => {
     if (isUnmounted) {
-      removeDom(parentNode)
+      if (!transition) removeDom(parentNode)
       return
     }
     // unset ref
@@ -935,18 +935,16 @@ function createVDOMComponent(
       )
       return
     }
-    // Vapor block removal and scope disposal can both reach this path.
-    // VDOM fragment ranges must only be removed once.
     isUnmounted = true
     isMounted = false
     internals.umt(vnode.component!, null, !!parentNode)
-    removeDom(parentNode)
+    // VDOM transitions own their leaving DOM until the leave finishes.
+    if (!transition) removeDom(parentNode)
   }
 
   frag.hydrate = () => {
     if (!isHydrating) return
     hydrateVNode(vnode, parentComponent as any)
-    onScopeDispose(unmount, true)
     isMounted = true
     frag.nodes = resolveVNodeNodes(vnode)
     frag.validityPending = false
@@ -984,7 +982,6 @@ function createVDOMComponent(
         )
         // set ref
         if (rawRef) vdomSetRef(rawRef, null, null, vnode)
-        onScopeDispose(unmount, true)
         isMounted = true
       } else {
         // move