Prechádzať zdrojové kódy

fix(runtime-vapor): clean up teleported keepalive slot content in vdom fragments (#14726)

edison 1 týždeň pred
rodič
commit
ad31f26bb1

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

@@ -43,6 +43,7 @@ import {
   createIf,
   createSlot,
   createTemplateRefSetter,
+  createVaporApp,
   defineVaporAsyncComponent,
   defineVaporComponent,
   insert,
@@ -2142,6 +2143,16 @@ describe('vdomInterop', () => {
   })
 
   describe('KeepAlive', () => {
+    const VDomCommentWrapper = defineComponent({
+      setup(_, { slots }) {
+        return () => [
+          createCommentVNode('before'),
+          renderSlot(slots, 'default'),
+          createCommentVNode('after'),
+        ]
+      },
+    })
+
     function assertHookCalls(
       hooks: {
         beforeMount: any
@@ -2375,6 +2386,231 @@ describe('vdomInterop', () => {
       expect(html()).toBe('<div><!----></div>')
     })
 
+    test('should remove teleported slot content when unmounting comment-wrapped vdom slot inside VaporKeepAlive', async () => {
+      const show = ref(true)
+      const target = document.createElement('div')
+      target.id = 'keepalive-teleport-target'
+      document.body.appendChild(target)
+
+      const App = defineVaporComponent({
+        setup() {
+          return createIf(
+            () => show.value,
+            () =>
+              createComponent(VDomCommentWrapper as any, null, {
+                default: withVaporCtx(() =>
+                  createComponent(VaporKeepAlive, null, {
+                    default: withVaporCtx(() =>
+                      createComponent(
+                        VaporTeleport,
+                        { to: () => '#keepalive-teleport-target' },
+                        {
+                          default: () => template('<input>')(),
+                        },
+                      ),
+                    ),
+                  }),
+                ),
+              }),
+          )
+        },
+      })
+
+      const host = document.createElement('div')
+      const app = createVaporApp(App)
+      app.use(vaporInteropPlugin)
+      app.mount(host)
+
+      try {
+        await nextTick()
+        expect(target.innerHTML).toBe('<input>')
+
+        show.value = false
+        await nextTick()
+
+        expect(target.innerHTML).toBe('')
+      } finally {
+        app.unmount()
+        host.remove()
+        target.remove()
+      }
+    })
+
+    test('should remove inline teleported slot content when disabled inside comment-wrapped vdom slot under VaporKeepAlive', async () => {
+      const show = ref(true)
+      const target = document.createElement('div')
+      target.id = 'keepalive-disabled-teleport-target'
+      document.body.appendChild(target)
+
+      const App = defineVaporComponent({
+        setup() {
+          return createIf(
+            () => show.value,
+            () =>
+              createComponent(VDomCommentWrapper as any, null, {
+                default: withVaporCtx(() =>
+                  createComponent(VaporKeepAlive, null, {
+                    default: withVaporCtx(() =>
+                      createComponent(
+                        VaporTeleport,
+                        {
+                          to: () => '#keepalive-disabled-teleport-target',
+                          disabled: () => true,
+                        },
+                        {
+                          default: () => template('<input>')(),
+                        },
+                      ),
+                    ),
+                  }),
+                ),
+              }),
+          )
+        },
+      })
+
+      const host = document.createElement('div')
+      const app = createVaporApp(App)
+      app.use(vaporInteropPlugin)
+      app.mount(host)
+
+      try {
+        await nextTick()
+        expect(host.querySelector('input')).not.toBeNull()
+        expect(target.innerHTML).toBe('')
+
+        show.value = false
+        await nextTick()
+
+        expect(host.querySelector('input')).toBeNull()
+        expect(target.innerHTML).toBe('')
+      } finally {
+        app.unmount()
+        host.remove()
+        target.remove()
+      }
+    })
+
+    test('should remove moved teleported slot content when comment-wrapped vdom slot under VaporKeepAlive unmounts', async () => {
+      const show = ref(true)
+      const to = ref('#keepalive-teleport-target-a')
+      const targetA = document.createElement('div')
+      targetA.id = 'keepalive-teleport-target-a'
+      const targetB = document.createElement('div')
+      targetB.id = 'keepalive-teleport-target-b'
+      document.body.append(targetA, targetB)
+
+      const App = defineVaporComponent({
+        setup() {
+          return createIf(
+            () => show.value,
+            () =>
+              createComponent(VDomCommentWrapper as any, null, {
+                default: withVaporCtx(() =>
+                  createComponent(VaporKeepAlive, null, {
+                    default: withVaporCtx(() =>
+                      createComponent(
+                        VaporTeleport,
+                        { to: () => to.value },
+                        {
+                          default: () => template('<input>')(),
+                        },
+                      ),
+                    ),
+                  }),
+                ),
+              }),
+          )
+        },
+      })
+
+      const host = document.createElement('div')
+      const app = createVaporApp(App)
+      app.use(vaporInteropPlugin)
+      app.mount(host)
+
+      try {
+        await nextTick()
+        expect(targetA.innerHTML).toBe('<input>')
+        expect(targetB.innerHTML).toBe('')
+
+        to.value = '#keepalive-teleport-target-b'
+        await nextTick()
+
+        expect(targetA.innerHTML).toBe('')
+        expect(targetB.innerHTML).toBe('<input>')
+
+        show.value = false
+        await nextTick()
+
+        expect(targetA.innerHTML).toBe('')
+        expect(targetB.innerHTML).toBe('')
+      } finally {
+        app.unmount()
+        host.remove()
+        targetA.remove()
+        targetB.remove()
+      }
+    })
+
+    test('should remove teleported slot content when KeepAlive is nested inside a vapor wrapper in comment-wrapped vdom slot', async () => {
+      const show = ref(true)
+      const target = document.createElement('div')
+      target.id = 'nested-keepalive-teleport-target'
+      document.body.appendChild(target)
+
+      const NestedKeepAlive = defineVaporComponent({
+        setup() {
+          return createComponent(VaporKeepAlive, null, {
+            default: withVaporCtx(() => createSlot('default')),
+          })
+        },
+      })
+
+      const App = defineVaporComponent({
+        setup() {
+          return createIf(
+            () => show.value,
+            () =>
+              createComponent(VDomCommentWrapper as any, null, {
+                default: withVaporCtx(() =>
+                  createComponent(NestedKeepAlive, null, {
+                    default: withVaporCtx(() =>
+                      createComponent(
+                        VaporTeleport,
+                        { to: () => '#nested-keepalive-teleport-target' },
+                        {
+                          default: () => template('<input>')(),
+                        },
+                      ),
+                    ),
+                  }),
+                ),
+              }),
+          )
+        },
+      })
+
+      const host = document.createElement('div')
+      const app = createVaporApp(App)
+      app.use(vaporInteropPlugin)
+      app.mount(host)
+
+      try {
+        await nextTick()
+        expect(target.innerHTML).toBe('<input>')
+
+        show.value = false
+        await nextTick()
+
+        expect(target.innerHTML).toBe('')
+      } finally {
+        app.unmount()
+        host.remove()
+        target.remove()
+      }
+    })
+
     test('should update props on reactivation of vapor child in vdom KeepAlive', async () => {
       const VaporChild = defineVaporComponent({
         props: { msg: String },

+ 24 - 1
packages/runtime-vapor/src/vdomInterop.ts

@@ -288,7 +288,17 @@ const vaporInteropImpl: Omit<
         }
       }
     } else if (vnode.vb) {
-      remove(vnode.vb, container)
+      const anchor = vnode.anchor as Node | null
+      // Fragment child unmounts invoke VaporSlot with doRemove = false, so the
+      // renderer does not pass us a container. Most slot blocks can still
+      // clean themselves up without it, but KeepAlive needs the host container
+      // to remove its current block and reach nested Teleport cleanup.
+      const blockContainer =
+        container ||
+        (needsSlotBlockUnmountContainer(vnode.vb)
+          ? ((anchor && anchor.parentNode) as ParentNode)
+          : undefined)
+      remove(vnode.vb, blockContainer)
       stopVaporSlotScope(vnode)
     }
     if (doRemove) {
@@ -1394,6 +1404,19 @@ function renderVDOMSlot(
   return frag
 }
 
+function needsSlotBlockUnmountContainer(block: Block): boolean {
+  if (isVaporComponent(block)) {
+    return isKeepAlive(block) || needsSlotBlockUnmountContainer(block.block)
+  }
+  if (isArray(block)) {
+    return block.some(needsSlotBlockUnmountContainer)
+  }
+  if (isFragment(block)) {
+    return needsSlotBlockUnmountContainer(block.nodes)
+  }
+  return false
+}
+
 export const vaporInteropPlugin: Plugin = app => {
   setInteropEnabled()
   const internals = ensureRenderer().internals