Преглед на файлове

fix(runtime-vapor): avoid duplicate vdom slot fallback evaluation

daiwei преди 1 седмица
родител
ревизия
9043d67ded

+ 7 - 1
packages/runtime-core/src/helpers/renderSlot.ts

@@ -19,6 +19,11 @@ import { warn } from '../warning'
 import { isAsyncWrapper } from '../apiAsyncComponent'
 import type { Data } from '../component'
 
+type SlotFallback = {
+  (): VNodeArrayChildren
+  __vdom?: boolean
+}
+
 /**
  * Compiler runtime helper for rendering `<slot/>`
  * @private
@@ -29,10 +34,11 @@ export function renderSlot(
   props: Data = {},
   // this is not a user-facing function, so the fallback is always generated by
   // the compiler and guaranteed to be a function returning an array
-  fallback?: () => VNodeArrayChildren,
+  fallback?: SlotFallback,
   noSlotted?: boolean,
 ): VNode {
   let slot = slots[name]
+  if (fallback) fallback.__vdom = true
 
   // vapor slots rendered in vdom
   // __vs: original vapor slot stored on a wrapper from vaporSlotsProxyHandler

+ 6 - 1
packages/runtime-core/src/index.ts

@@ -609,7 +609,12 @@ export { setRef } from './rendererTemplateRef'
 /**
  * @internal
  */
-export { VaporSlot, type VNodeNormalizedRef, normalizeRef } from './vnode'
+export {
+  VaporSlot,
+  normalizeVNode,
+  type VNodeNormalizedRef,
+  normalizeRef,
+} from './vnode'
 /**
  * @internal
  */

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

@@ -2108,6 +2108,78 @@ describe('component: slots', () => {
         expect(root.innerHTML).toBe('<p>alt fallback</p><!--slot-->')
       })
 
+      test('vdom fallback for renderVaporSlot is evaluated once on initial mount', async () => {
+        const fallbackText = ref('fallback')
+        const fallbackSpy = vi.fn(() => [h('div', fallbackText.value)])
+
+        const VdomSlotWithCountingFallback = {
+          render(this: any) {
+            return renderSlot(this.$slots, 'foo', {}, fallbackSpy)
+          },
+        }
+
+        const VaporForwardedSlot = defineVaporComponent({
+          setup() {
+            return createComponent(
+              VdomSlotWithCountingFallback,
+              null,
+              {
+                foo: withVaporCtx(() => createSlot('foo', null)),
+              },
+              true,
+            )
+          },
+        })
+
+        const root = document.createElement('div')
+        createVaporApp(VaporForwardedSlot).use(vaporInteropPlugin).mount(root)
+
+        expect(root.innerHTML).toBe('<div>fallback</div><!--slot-->')
+        expect(fallbackSpy).toHaveBeenCalledTimes(1)
+
+        fallbackText.value = 'updated fallback'
+        await nextTick()
+
+        expect(root.innerHTML).toBe('<div>updated fallback</div><!--slot-->')
+        expect(fallbackSpy).toHaveBeenCalledTimes(2)
+      })
+
+      test('vdom fallback for renderVaporSlot supports text children', async () => {
+        const fallbackText = ref('fallback')
+        const fallbackSpy = vi.fn(() => [fallbackText.value])
+
+        const VdomSlotWithTextFallback = {
+          render(this: any) {
+            return renderSlot(this.$slots, 'foo', {}, fallbackSpy)
+          },
+        }
+
+        const VaporForwardedSlot = defineVaporComponent({
+          setup() {
+            return createComponent(
+              VdomSlotWithTextFallback,
+              null,
+              {
+                foo: withVaporCtx(() => createSlot('foo', null)),
+              },
+              true,
+            )
+          },
+        })
+
+        const root = document.createElement('div')
+        createVaporApp(VaporForwardedSlot).use(vaporInteropPlugin).mount(root)
+
+        expect(root.innerHTML).toBe('fallback<!--slot-->')
+        expect(fallbackSpy).toHaveBeenCalledTimes(1)
+
+        fallbackText.value = 'updated fallback'
+        await nextTick()
+
+        expect(root.innerHTML).toBe('updated fallback<!--slot-->')
+        expect(fallbackSpy).toHaveBeenCalledTimes(2)
+      })
+
       test('moving active vdom fallback keeps slot carrier order after teleport move', async () => {
         const targetA = document.createElement('div')
         targetA.id = 'component-slots-fallback-target-a'

+ 30 - 33
packages/runtime-vapor/src/vdomInterop.ts

@@ -9,9 +9,7 @@ import {
   type KeepAliveContext,
   MoveType,
   type Plugin,
-  type RendererElement,
   type RendererInternals,
-  type RendererNode,
   type ShallowRef,
   type Slots,
   Static,
@@ -36,6 +34,7 @@ import {
   isVNode,
   isHydrating as isVdomHydrating,
   normalizeRef,
+  normalizeVNode,
   onScopeDispose,
   queuePostFlushCb,
   renderSlot,
@@ -1433,12 +1432,26 @@ function hydrateVNode(
   else advanceHydrationNode(node)
 }
 
-function createVaporFallback(
-  fallback: () => any,
+function createFallback(
+  fallback: InteropSlotFallback,
   parentComponent: ComponentInternalInstance | null,
+  isVNodeFallback: () => boolean,
 ): BlockFn {
   const internals = ensureRenderer().internals
-  return () => createFallback(fallback)(internals, parentComponent)
+  return () => {
+    if (isVNodeFallback()) {
+      const frag = createVNodeChildrenFragment(
+        internals,
+        () => (fallback() as VNodeArrayChildren).map(normalizeVNode),
+        parentComponent,
+      )
+      if (isHydrating && frag.hydrate) {
+        frag.hydrate()
+      }
+      return frag
+    }
+    return fallback() as Block
+  }
 }
 
 const renderEmptyVNodes = (): VNodeArrayChildren => []
@@ -1458,32 +1471,10 @@ function runWithFragmentRenderCtx<R>(fragment: VaporFragment, fn: () => R): R {
   }
 }
 
-const createFallback =
-  (fallback: () => any) =>
-  (
-    internals: RendererInternals<RendererNode, RendererElement>,
-    parentComponent: ComponentInternalInstance | null,
-  ) => {
-    const fallbackNodes = fallback()
-
-    // vnode content, wrap it as a VaporFragment
-    if (isArray(fallbackNodes) && fallbackNodes.every(isVNode)) {
-      const frag = createVNodeChildrenFragment(
-        internals,
-        () => fallback() as VNode[],
-        parentComponent,
-      )
-      if (isHydrating && frag.hydrate) {
-        frag.hydrate()
-      }
-      return frag
-    }
-
-    // vapor block
-    return fallbackNodes as Block
-  }
-
-type InteropSlotFallback = () => any
+type InteropSlotFallback = {
+  (): any
+  __vdom?: boolean
+}
 
 interface InteropVaporSlotState {
   localFallback: ShallowRef<InteropSlotFallback | undefined>
@@ -1631,15 +1622,21 @@ function renderVaporSlot(
 
     try {
       wrappedLocalFallback = controller.wrapFallback(
-        createVaporFallback(
+        createFallback(
           () => (slotState.localFallback.value || renderEmptyVNodes)(),
           parentComponent,
+          () =>
+            !!slotState.localFallback.value &&
+            !!slotState.localFallback.value.__vdom,
         ),
       )
       wrappedOutletFallback = controller.wrapFallback(
-        createVaporFallback(
+        createFallback(
           () => (slotState.outletFallback.value || renderEmptyVNodes)(),
           parentComponent,
+          () =>
+            !!slotState.outletFallback.value &&
+            !!slotState.outletFallback.value.__vdom,
         ),
       )
       const preferSlotFragmentOwnership =