Ver código fonte

fix(runtime-vapor): stop stale effects when remounting interop vapor slots

daiwei 3 semanas atrás
pai
commit
3a1cfe4bb7

+ 2 - 0
packages/runtime-core/src/vnode.ts

@@ -24,6 +24,7 @@ import {
 } from './component'
 import type { RawSlots } from './componentSlots'
 import {
+  type EffectScope,
   type ReactiveFlags,
   type Ref,
   type ShallowRef,
@@ -270,6 +271,7 @@ export interface VNode<
     slot: (props: any) => any
     fallback: (() => VNodeArrayChildren) | undefined
     ref?: ShallowRef<any>
+    scope?: EffectScope
   }
   /**
    * @internal Vapor slot Block

+ 63 - 0
packages/runtime-vapor/__tests__/hydration.spec.ts

@@ -5883,6 +5883,69 @@ describe('VDOM interop', () => {
     )
   })
 
+  test('hydrate dynamic vapor slot re-mount should stop stale effects from previous slot function', async () => {
+    const staleState = reactive({ id: 0, text: 'zero' })
+    const activeState = reactive({ id: 1, text: 'one' })
+    const nextState = reactive({ id: 2, text: 'two' })
+    const data = reactive({
+      items: [staleState, activeState],
+      track: vi.fn((_: number, text: string) => text),
+    })
+
+    const { container } = await testWithVaporApp(
+      `<script setup vapor>
+        const data = _data
+        const components = _components
+      </script>
+      <template>
+        <components.Comp>
+          <template v-for="item in data.items" #default>
+            <span>{{ data.track(item.id, item.text) }}</span>
+          </template>
+        </components.Comp>
+      </template>`,
+      {
+        Comp: {
+          code: `
+          <template>
+            <div>
+              <slot />
+            </div>
+          </template>`,
+          vapor: false,
+        },
+      },
+      data,
+    )
+
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(`
+      "<div>
+      <!--[--><span>one</span><!--]-->
+      </div>"
+    `)
+
+    expect(`Hydration node mismatch`).not.toHaveBeenWarned()
+
+    data.items.push(nextState)
+    await nextTick()
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(`
+      "<div>
+      <!--[--><span>two</span><!--]-->
+      </div>"
+    `)
+
+    data.track.mockClear()
+    activeState.text = 'stale-one'
+    await nextTick()
+
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(`
+      "<div>
+      <!--[--><span>two</span><!--]-->
+      </div>"
+    `)
+    expect(data.track).not.toHaveBeenCalled()
+  })
+
   test('hydrate multi-root VNode component via createDynamicComponent and switch branch', async () => {
     const data = ref({
       showMulti: true,

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

@@ -1014,6 +1014,67 @@ describe('vdomInterop', () => {
       await nextTick()
       expect(html()).toBe('<div><span>1</span><!--if--></div>')
     })
+
+    test('dynamic slot re-mount should stop stale effects from previous slot function', async () => {
+      const list = ref([0, 1])
+      const slotStates = new Map([
+        [0, { text: ref('zero'), runs: vi.fn() }],
+        [1, { text: ref('one'), runs: vi.fn() }],
+        [2, { text: ref('two'), runs: vi.fn() }],
+      ])
+
+      const VDomChild = defineComponent({
+        setup(_, { slots }) {
+          return () => h('div', null, [renderSlot(slots, 'default')])
+        },
+      })
+
+      const VaporParent = defineVaporComponent({
+        setup() {
+          return createComponent(VDomChild as any, null, {
+            $: [
+              () =>
+                createForSlots(list.value, value => ({
+                  name: 'default',
+                  fn: () => {
+                    const state = slotStates.get(value)!
+                    const n = template('<span> </span>')() as Element
+                    const t = txt(n) as Text
+                    renderEffect(() => {
+                      state.runs()
+                      setText(t, state.text.value)
+                    })
+                    return n
+                  },
+                })),
+            ],
+          })
+        },
+      })
+
+      const { html } = define({
+        setup() {
+          return () => h(VaporParent as any)
+        },
+      }).render()
+
+      const firstState = slotStates.get(1)!
+
+      expect(html()).toBe('<div><span>one</span></div>')
+      expect(firstState.runs).toHaveBeenCalledTimes(1)
+
+      list.value.push(2)
+      await nextTick()
+
+      expect(html()).toBe('<div><span>two</span></div>')
+      expect(firstState.runs).toHaveBeenCalledTimes(1)
+
+      firstState.text.value = 'stale-one'
+      await nextTick()
+
+      expect(html()).toBe('<div><span>two</span></div>')
+      expect(firstState.runs).toHaveBeenCalledTimes(1)
+    })
   })
 
   describe('provide / inject', () => {

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

@@ -47,6 +47,7 @@ import {
   setRef as vdomSetRef,
   warn,
 } from '@vue/runtime-dom'
+import { effectScope } from '@vue/reactivity'
 import {
   type LooseRawProps,
   type LooseRawSlots,
@@ -281,6 +282,7 @@ const vaporInteropImpl: Omit<
       }
     } else if (vnode.vb) {
       remove(vnode.vb, container)
+      stopVaporSlotScope(vnode)
     }
     remove(vnode.anchor as Node, container)
     // invoke onVnodeUnmounted hook
@@ -329,6 +331,7 @@ const vaporInteropImpl: Omit<
           isFragment(n1.vb!) && n1.vb!.anchor === selfAnchor
         // remove old vapor block
         remove(n1.vb!, parent)
+        stopVaporSlotScope(n1)
         const slotBlock = renderVaporSlot(n2, parentComponent, parentSuspense)
         let newAnchor = isFragment(slotBlock) ? slotBlock.anchor : undefined
         let insertAnchor = nextSibling as Node
@@ -348,6 +351,7 @@ const vaporInteropImpl: Omit<
         n2.el = n2.anchor = n1.anchor
         n2.vb = n1.vb
         ;(n2.vs!.ref = n1.vs!.ref)!.value = n2.props
+        n2.vs!.scope = n1.vs!.scope
       }
     }
   },
@@ -371,10 +375,8 @@ const vaporInteropImpl: Omit<
 
   hydrateSlot(vnode, node) {
     if (!isHydrating && !isVdomHydrating) return node
-    const { slot } = vnode.vs!
-    const propsRef = (vnode.vs!.ref = shallowRef(vnode.props))
     vaporHydrateNode(node, () => {
-      vnode.vb = slot(new Proxy(propsRef, vaporSlotPropsProxyHandler))
+      vnode.vb = invokeVaporSlot(vnode)
       vnode.anchor = vnode.el = currentHydrationNode!
 
       if (__DEV__ && !vnode.anchor) {
@@ -1213,9 +1215,8 @@ function renderVaporSlot(
     prevSuspense = setParentSuspense(parentSuspense)
   }
   try {
-    const { slot, fallback } = vnode.vs!
-    const propsRef = (vnode.vs!.ref = shallowRef(vnode.props))
-    let slotBlock = slot(new Proxy(propsRef, vaporSlotPropsProxyHandler))
+    const { fallback } = vnode.vs!
+    let slotBlock = invokeVaporSlot(vnode)
     if (!fallback) {
       return slotBlock
     }
@@ -1233,3 +1234,30 @@ function renderVaporSlot(
     simpleSetCurrentInstance(prev)
   }
 }
+
+function stopVaporSlotScope(vnode: VNode): void {
+  if (vnode.vs && vnode.vs.scope) {
+    vnode.vs.scope.stop()
+    vnode.vs.scope = undefined
+  }
+}
+
+/**
+ * Slot functions can create renderEffects while evaluating their block.
+ * Those effects live in this dedicated scope so slot re-mount/unmount can
+ * dispose them immediately instead of waiting for the parent component.
+ */
+function invokeVaporSlot(vnode: VNode): Block {
+  const propsRef = (vnode.vs!.ref = shallowRef(vnode.props))
+  const scope = effectScope()
+  vnode.vs!.scope = scope
+  try {
+    return scope.run(() =>
+      vnode.vs!.slot(new Proxy(propsRef, vaporSlotPropsProxyHandler)),
+    )!
+  } catch (e) {
+    vnode.vs!.scope = undefined
+    scope.stop()
+    throw e
+  }
+}