Преглед изворни кода

fix(runtime-vapor): isolate slotProps per fragment in v-for slots (#14406)

close #14397
edison пре 2 месеци
родитељ
комит
9db9f1e174

+ 42 - 0
packages/runtime-vapor/__tests__/apiCreateSelector.spec.ts

@@ -60,4 +60,46 @@ describe('api: createSelector', () => {
     // )
     // expect(calledTimes).toBe((expectedCalledTimes += 1))
   })
+
+  test('selector runs after list updates when value changes first', async () => {
+    const items = ref<number[]>([])
+    const index = ref(-1)
+
+    const { host } = define(() => {
+      let selector: (cb: () => void) => void
+      return createFor(
+        () => items.value,
+        item => {
+          const div = document.createElement('div')
+          selector(() => {
+            div.className = index.value === item.value ? 'active' : ''
+          })
+          const btn = document.createElement('button')
+          btn.textContent = String(item.value)
+          div.appendChild(btn)
+          return div
+        },
+        item => item,
+        undefined,
+        ({ createSelector }) => {
+          selector = createSelector(() => index.value)
+        },
+      )
+    }).render()
+
+    expect(host.innerHTML).toBe('<!--for-->')
+
+    index.value = 0
+    items.value.push(0)
+    await nextTick()
+    expect(host.innerHTML).toBe(
+      '<div class="active"><button>0</button></div><!--for-->',
+    )
+
+    index.value = -1
+    await nextTick()
+    expect(host.innerHTML).toBe(
+      '<div class=""><button>0</button></div><!--for-->',
+    )
+  })
 })

+ 44 - 0
packages/runtime-vapor/__tests__/componentSlots.spec.ts

@@ -181,6 +181,50 @@ describe('component: slots', () => {
       expect(host.innerHTML).toBe('<div><h1>footer</h1><!--slot--></div>')
     })
 
+    test('slot props should be isolated per fragment in v-for', async () => {
+      const items = ref([0, 1, 2])
+
+      const Child = defineVaporComponent(() => {
+        const list = createFor(
+          () => items.value,
+          for_item0 => {
+            const n0 = template('<div></div>')()
+            insert(
+              createSlot('age-option', { age: () => for_item0.value }),
+              n0 as any as ParentNode,
+            )
+            return n0
+          },
+        )
+        return list
+      })
+
+      const { host } = define(() => {
+        return createComponent(Child, null, {
+          'age-option': (props: any) => {
+            const el = template('<span></span>')()
+            renderEffect(() => {
+              setElementText(el, toDisplayString(props.age))
+            })
+            return el
+          },
+        })
+      }).render()
+
+      expect(host.innerHTML).toBe(
+        '<div><span>0</span><!--slot--></div>' +
+          '<div><span>1</span><!--slot--></div>' +
+          '<div><span>2</span><!--slot--></div><!--for-->',
+      )
+
+      items.value = [3, 4]
+      await nextTick()
+      expect(host.innerHTML).toBe(
+        '<div><span>3</span><!--slot--></div>' +
+          '<div><span>4</span><!--slot--></div><!--for-->',
+      )
+    })
+
     test('dynamic slot props', async () => {
       let props: any
 

+ 12 - 6
packages/runtime-vapor/src/apiCreateFor.ts

@@ -20,7 +20,7 @@ import {
   insert,
   remove,
 } from './block'
-import { warn } from '@vue/runtime-dom'
+import { queuePostFlushCb, warn } from '@vue/runtime-dom'
 import { currentInstance, isVaporComponent } from './component'
 import {
   type DynamicSlot,
@@ -522,12 +522,18 @@ export const createFor = (
           oper()
         }
       }
-      activeOpers = operMap.get(newValue)
-      if (activeOpers !== undefined) {
-        for (const oper of activeOpers) {
-          oper()
+
+      // watch may trigger before list patched
+      // defer to post-flush so operMap is up to date
+      queuePostFlushCb(() => {
+        activeKey = newValue
+        activeOpers = operMap.get(newValue)
+        if (activeOpers !== undefined) {
+          for (const oper of activeOpers) {
+            oper()
+          }
         }
-      }
+      })
     })
 
     selectors.push({ deregister, cleanup })

+ 31 - 21
packages/runtime-vapor/src/componentSlots.ts

@@ -20,7 +20,11 @@ import {
   isHydrating,
   locateHydrationNode,
 } from './dom/hydration'
-import { SlotFragment, type VaporFragment } from './fragment'
+import {
+  type DynamicFragment,
+  SlotFragment,
+  type VaporFragment,
+} from './fragment'
 import { createElement } from './dom/node'
 import { setDynamicProps } from './dom/prop'
 
@@ -96,7 +100,9 @@ export const dynamicSlotsProxyHandlers: ProxyHandler<RawSlots> = {
 export function getSlot(
   target: RawSlots,
   key: string,
-): (VaporSlot & { _bound?: VaporSlot }) | undefined {
+):
+  | (VaporSlot & { _boundMap?: WeakMap<DynamicFragment, VaporSlot> })
+  | undefined {
   if (key === '$') return
   const dynamicSources = target.$
   if (dynamicSources) {
@@ -237,25 +243,29 @@ export function createSlot(
 
       const slot = getSlot(rawSlots, slotName)
       if (slot) {
-        // Create and cache bound version of the slot to make it stable
-        // so that we avoid unnecessary updates if it resolves to the same slot
-        fragment.updateSlot(
-          slot._bound ||
-            (slot._bound = () => {
-              const prevSlotScopeIds = setCurrentSlotScopeIds(
-                slotScopeIds.length > 0 ? slotScopeIds : null,
-              )
-              const prev = inOnceSlot
-              try {
-                if (once) inOnceSlot = true
-                return slot(slotProps)
-              } finally {
-                inOnceSlot = prev
-                setCurrentSlotScopeIds(prevSlotScopeIds)
-              }
-            }),
-          fallback,
-        )
+        // Create and cache bound slot to keep it stable and avoid unnecessary
+        // updates when it resolves to the same slot. Cache per-fragment
+        // (v-for creates multiple fragments) so each fragment keeps its own
+        // slotProps without cross-talk.
+        const boundMap = slot._boundMap || (slot._boundMap = new WeakMap())
+        let bound = boundMap.get(fragment)
+        if (!bound) {
+          bound = () => {
+            const prevSlotScopeIds = setCurrentSlotScopeIds(
+              slotScopeIds.length > 0 ? slotScopeIds : null,
+            )
+            const prev = inOnceSlot
+            try {
+              if (once) inOnceSlot = true
+              return slot(slotProps)
+            } finally {
+              inOnceSlot = prev
+              setCurrentSlotScopeIds(prevSlotScopeIds)
+            }
+          }
+          boundMap.set(fragment, bound)
+        }
+        fragment.updateSlot(bound, fallback)
       } else {
         fragment.updateSlot(undefined, fallback)
       }