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

refactor(runtime-vapor): share transition leaf traversal

daiwei пре 1 недеља
родитељ
комит
2bf63c2618

+ 32 - 1
packages/runtime-vapor/__tests__/components/Transition.spec.ts

@@ -4,7 +4,10 @@ import {
   setBlockKey,
   template,
 } from '../../src'
-import { resolveTransitionBlock } from '../../src/components/Transition'
+import {
+  resolveTransitionBlock,
+  resolveTransitionBlocks,
+} from '../../src/components/Transition'
 import { nextTick, ref } from 'vue'
 import { compile, makeInteropRender, makeRender } from '../_utils'
 
@@ -119,6 +122,34 @@ describe('Transition', () => {
     expect(resolved.$key).toBe(child.uid)
   })
 
+  test('collects group leaves with component key prefixes', () => {
+    const Child = defineVaporComponent({
+      setup() {
+        return [
+          document.createComment('anchor'),
+          template(`<div>a</div>`)() as any,
+          template(`<div>b</div>`)() as any,
+        ]
+      },
+    })
+
+    let child: any
+    define({
+      setup() {
+        child = createComponent(Child)
+        setBlockKey(child, 'foo')
+        child.block[1].$key = undefined
+        child.block[2].$key = undefined
+        return child
+      },
+    }).render()
+
+    const resolved = resolveTransitionBlocks(child)
+    expect(resolved).toEqual([child.block[1], child.block[2]])
+    expect(child.block[1].$key).toBe('foo0')
+    expect(child.block[2].$key).toBe('foo1')
+  })
+
   test('allows empty transition content', async () => {
     const App = compile(`<template><Transition /></template>`, ref({}))
     const { host } = define(App as any).render()

+ 2 - 1
packages/runtime-vapor/src/block.ts

@@ -28,7 +28,8 @@ export interface VaporTransitionHooks extends TransitionHooks {
   state: TransitionState
   props: TransitionProps
   instance: VaporComponentInstance
-  // mark transition hooks as disabled
+  // Temporarily skips enter/move during TransitionGroup FLIP measurement.
+  // Leave transitions intentionally ignore this flag.
   disabled?: boolean
   // TransitionGroup sets this to handle applying hooks to list children
   applyGroup?: (

+ 202 - 63
packages/runtime-vapor/src/components/Transition.ts

@@ -11,7 +11,6 @@ import {
   baseResolveTransitionHooks,
   checkTransitionMode,
   currentInstance,
-  getComponentName,
   getTransitionRawChildren,
   isAsyncWrapper,
   isTemplateNode,
@@ -39,6 +38,7 @@ import { isArray } from '@vue/shared'
 import { renderEffect } from '../renderEffect'
 import {
   DynamicFragment,
+  ForBlock,
   ForFragment,
   type VaporFragment,
   isFragment,
@@ -59,6 +59,12 @@ export type ResolvedTransitionBlock = (
 ) &
   TransitionOptions
 
+type ResolveTransitionBlocksOptions = {
+  mode: 'single' | 'group'
+  onFragment?: (frag: VaporFragment) => void
+  onUpdateOwner?: (owner: VaporFragment | VaporComponentInstance) => void
+}
+
 let registered = false
 export const ensureTransitionHooksRegistered = (): void => {
   if (!registered) {
@@ -219,13 +225,8 @@ function getLeavingNodesForType(
 function getLeaveElement(
   block: ResolvedTransitionBlock,
 ): TransitionElement | undefined {
-  if (block instanceof Element) {
-    return block as TransitionElement
-  }
-  if (isInteropEnabled && isFragment(block) && block.vnode) {
-    const el = getTransitionElementFromVNode(block.vnode)
-    if (el) return el as TransitionElement
-  }
+  const el = getTransitionElement(block)
+  if (el) return el as TransitionElement
   if (
     isFragment(block) &&
     !isArray(block.nodes) &&
@@ -440,73 +441,194 @@ export function resolveTransitionBlock(
   block: Block,
   onFragment?: (frag: VaporFragment) => void,
 ): ResolvedTransitionBlock | undefined {
-  let child: ResolvedTransitionBlock | undefined
+  return resolveTransitionChildren(block, { mode: 'single', onFragment })[0]
+}
+
+export function resolveTransitionBlocks(
+  block: Block,
+  onFragment?: (frag: VaporFragment) => void,
+  onUpdateOwner?: (owner: VaporFragment | VaporComponentInstance) => void,
+): ResolvedTransitionBlock[] {
+  return resolveTransitionChildren(block, {
+    mode: 'group',
+    onFragment,
+    onUpdateOwner,
+  })
+}
+
+function resolveTransitionChildren(
+  block: Block,
+  options: ResolveTransitionBlocksOptions,
+): ResolvedTransitionBlock[] {
+  const children: ResolvedTransitionBlock[] = []
+  collectTransitionBlocks(block, options, children)
+  return children
+}
+
+function collectTransitionBlocks(
+  block: Block,
+  options: ResolveTransitionBlocksOptions,
+  children: ResolvedTransitionBlock[],
+): void {
   if (block instanceof Node) {
     // transition can only be applied on Element child
-    if (block instanceof Element) child = block
+    if (block instanceof Element) children.push(block)
   } else if (isVaporComponent(block)) {
-    if (isAsyncWrapper(block)) {
-      // for unresolved async wrapper, set transition hooks on inner fragment
-      if (!block.type.__asyncResolved) {
-        onFragment && onFragment(block.block! as DynamicFragment)
-      } else {
-        child = resolveTransitionBlock(
-          (block.block! as DynamicFragment).nodes,
-          onFragment,
-        )
-        if (child) {
-          if (child.$key == null) {
-            child.$key = block.$key ?? block.uid
-          }
-          // align with normal component branches so leaving cache can
-          // distinguish different resolved async wrapper types.
-          transitionTypeMap.set(child, block.type)
-        }
-      }
-    } else {
-      // stop searching if encountering nested Transition component
-      if (getComponentName(block.type) === displayName) return undefined
-      child = resolveTransitionBlock(block.block, onFragment)
-      if (child) {
-        if (child.$key == null) {
-          // prefer explicit component key, otherwise fall back to uid.
-          child.$key = block.$key ?? block.uid
-        }
-        transitionTypeMap.set(child, block.type)
-      }
-    }
+    collectComponentTransitionBlocks(block, options, children)
   } else if (isArray(block)) {
-    let hasFound = false
+    collectArrayTransitionBlocks(block, options, children)
+  } else if (isFragment(block)) {
+    collectFragmentTransitionBlocks(block, options, children)
+  }
+}
+
+function collectComponentTransitionBlocks(
+  block: VaporComponentInstance,
+  options: ResolveTransitionBlocksOptions,
+  children: ResolvedTransitionBlock[],
+): void {
+  if (options.mode === 'group') {
+    // A normal component child can move when parent-driven props update its
+    // root layout without re-running the surrounding v-for fragment.
+    // When the component root is a slot, the TransitionGroup children are the
+    // slotted blocks, so track the slot fragment instead of the component.
+    const isRootSlot = block.block && isSlotFragment(block.block)
+    if (options.onUpdateOwner && !isRootSlot) options.onUpdateOwner(block)
+
+    const start = children.length
+    collectTransitionBlocks(
+      block.block,
+      isRootSlot
+        ? options
+        : {
+            mode: options.mode,
+            onFragment: options.onFragment,
+          },
+      children,
+    )
+    inheritTransitionKey(children, start, block.$key)
+    return
+  }
+
+  if (isAsyncWrapper(block)) {
+    // for unresolved async wrapper, set transition hooks on inner fragment
+    if (!block.type.__asyncResolved) {
+      if (options.onFragment)
+        options.onFragment(block.block! as DynamicFragment)
+      return
+    }
+
+    const start = children.length
+    collectTransitionBlocks(
+      (block.block! as DynamicFragment).nodes,
+      options,
+      children,
+    )
+    inheritSingleComponentKey(children[start], block)
+    return
+  }
+
+  // stop searching if encountering nested Transition component
+  if (block.type === VaporTransition) return
+
+  const start = children.length
+  collectTransitionBlocks(block.block, options, children)
+  inheritSingleComponentKey(children[start], block)
+}
+
+function collectArrayTransitionBlocks(
+  block: Block[],
+  options: ResolveTransitionBlocksOptions,
+  children: ResolvedTransitionBlock[],
+): void {
+  if (options.mode === 'group') {
     for (const c of block) {
-      if (c instanceof Comment) continue
-      const item = resolveTransitionBlock(c, onFragment)
-      if (__DEV__ && hasFound) {
-        // warn more than one non-comment child
-        warn(
-          '<transition> can only be used on a single element or component. ' +
-            'Use <transition-group> for lists.',
-        )
-        break
+      const start = children.length
+      collectTransitionBlocks(c, options, children)
+      if (c instanceof ForBlock) {
+        for (let j = start; j < children.length; j++) {
+          children[j].$key = c.key
+        }
       }
-      child = item
-      hasFound = true
-      if (!__DEV__) break
     }
-  } else if (isFragment(block)) {
+    return
+  }
+
+  let hasFound = false
+  for (const c of block) {
+    if (c instanceof Comment) continue
+    const nested: ResolvedTransitionBlock[] = []
+    collectTransitionBlocks(c, options, nested)
+    if (__DEV__ && hasFound) {
+      // warn more than one non-comment child
+      warn(
+        '<transition> can only be used on a single element or component. ' +
+          'Use <transition-group> for lists.',
+      )
+      break
+    }
+    if (nested.length) children.push(nested[0])
+    hasFound = true
+    if (!__DEV__) break
+  }
+}
+
+function collectFragmentTransitionBlocks(
+  block: VaporFragment,
+  options: ResolveTransitionBlocksOptions,
+  children: ResolvedTransitionBlock[],
+): void {
+  if (options.mode === 'group') {
+    if (options.onFragment) options.onFragment(block)
+    if (options.onUpdateOwner) options.onUpdateOwner(block)
     if (isInteropEnabled && block.vnode) {
-      child = block
-      const children = getTransitionRawChildren([block.vnode])
-      if (children.length === 1) {
-        transitionTypeMap.set(child, children[0].type)
-      }
+      // vdom component
+      children.push(block)
     } else {
-      // collect fragments for setting transition hooks
-      if (onFragment) onFragment(block)
-      child = resolveTransitionBlock(block.nodes, onFragment)
+      const start = children.length
+      collectTransitionBlocks(block.nodes, options, children)
+      inheritTransitionKey(children, start, block.$key)
+    }
+    return
+  }
+
+  if (isInteropEnabled && block.vnode) {
+    children.push(block)
+    const rawChildren = getTransitionRawChildren([block.vnode])
+    if (rawChildren.length === 1) {
+      transitionTypeMap.set(block, rawChildren[0].type)
     }
+    return
   }
 
-  return child
+  // collect fragments for setting transition hooks
+  if (options.onFragment) options.onFragment(block)
+  collectTransitionBlocks(block.nodes, options, children)
+}
+
+function inheritSingleComponentKey(
+  child: ResolvedTransitionBlock | undefined,
+  block: VaporComponentInstance,
+): void {
+  if (!child) return
+  if (child.$key == null) {
+    // prefer explicit component key, otherwise fall back to uid.
+    child.$key = block.$key ?? block.uid
+  }
+  transitionTypeMap.set(child, block.type)
+}
+
+function inheritTransitionKey(
+  children: ResolvedTransitionBlock[],
+  start: number,
+  key: any,
+): void {
+  if (key === undefined || start === children.length) return
+  for (let i = start; i < children.length; i++) {
+    const child = children[i]
+    child.$key =
+      String(key) + String(child.$key != null ? child.$key : i - start)
+  }
 }
 
 export function setTransitionHooks(
@@ -544,6 +666,23 @@ export function getTransitionElementFromVNode(
   }
 }
 
+export function isValidTransitionBlock(
+  block: Block,
+): block is ResolvedTransitionBlock {
+  return !!(block instanceof Element || (isFragment(block) && block.vnode))
+}
+
+export function getTransitionElement(
+  block: ResolvedTransitionBlock,
+): Element | undefined {
+  if (block instanceof Element) return block
+
+  // vdom interop
+  if (isInteropEnabled && isFragment(block) && block.vnode) {
+    return getTransitionElementFromVNode(block.vnode)
+  }
+}
+
 function capturePendingVShows<T>(
   enabled: boolean,
   render: () => T,

+ 13 - 91
packages/runtime-vapor/src/components/TransitionGroup.ts

@@ -21,7 +21,7 @@ import {
   vShowHidden,
   warn,
 } from '@vue/runtime-dom'
-import { extend, isArray } from '@vue/shared'
+import { extend } from '@vue/shared'
 import {
   type Block,
   type BlockFn,
@@ -32,30 +32,25 @@ import { renderEffect } from '../renderEffect'
 import {
   type ResolvedTransitionBlock,
   ensureTransitionHooksRegistered,
-  getTransitionElementFromVNode,
+  getTransitionElement,
+  isValidTransitionBlock,
+  resolveTransitionBlocks,
   resolveTransitionHooks,
   setTransitionHooks,
 } from './Transition'
-import {
-  type VaporComponentInstance,
-  type VaporComponentOptions,
-  isVaporComponent,
+import type {
+  VaporComponentInstance,
+  VaporComponentOptions,
 } from '../component'
 import { resolveDynamicProps } from '../componentProps'
-import { isForBlock, setForHydrationAnchorResolver } from '../apiCreateFor'
+import { setForHydrationAnchorResolver } from '../apiCreateFor'
 import { createComment, createElement, createTextNode } from '../dom/node'
-import {
-  DynamicFragment,
-  type VaporFragment,
-  isFragment,
-  isSlotFragment,
-} from '../fragment'
+import { DynamicFragment, type VaporFragment, isFragment } from '../fragment'
 import {
   type DefineVaporComponent,
   defineVaporComponent,
 } from '../apiDefineComponent'
 import { watch } from '@vue/reactivity'
-import { isInteropEnabled } from '../vdomInteropState'
 import {
   adoptTemplate,
   cleanupHydrationTail,
@@ -161,7 +156,7 @@ const VaporTransitionGroupImpl = defineVaporComponent({
       if (isUpdatePending) return
       isUpdatePending = true
       prevChildren = []
-      const children = getTransitionBlocks(slottedBlock)
+      const children = resolveTransitionBlocks(slottedBlock)
       for (let i = 0; i < children.length; i++) {
         const child = children[i]
         const el =
@@ -174,9 +169,8 @@ const VaporTransitionGroupImpl = defineVaporComponent({
           !(el as VShowElement)[vShowHidden]
         ) {
           prevChildren.push(child)
-          // disabled transition during enter, so the children will be
-          // inserted into the correct position immediately. this prevents
-          // `recordPosition` from getting incorrect positions in `onUpdated`
+          // Skip enter/move while children are placed for FLIP measurement.
+          // Leave still needs to run for removed children.
           child.$transition!.disabled = true
           positionMap.set(child, el.getBoundingClientRect())
         }
@@ -318,7 +312,7 @@ function applyGroupTransitionHooks(
   updateHooks: TransitionGroupUpdateHookRef,
 ): ResolvedTransitionBlock[] {
   const fragments: VaporFragment[] = []
-  const children = getTransitionBlocks(
+  const children = resolveTransitionBlocks(
     block,
     frag => fragments.push(frag),
     owner => trackTransitionGroupUpdate(owner, updateHooks),
@@ -396,78 +390,6 @@ function trackTransitionGroupUpdate(
   }
 }
 
-function inheritKey(children: TransitionBlock[], key: any): void {
-  if (key === undefined || children.length === 0) return
-  for (let i = 0; i < children.length; i++) {
-    const child = children[i]
-    child.$key = String(key) + String(child.$key != null ? child.$key : i)
-  }
-}
-
-function getTransitionBlocks(
-  block: Block,
-  onFragment?: (frag: VaporFragment) => void,
-  onUpdateOwner?: (owner: TransitionGroupUpdateOwner) => void,
-): ResolvedTransitionBlock[] {
-  let children: ResolvedTransitionBlock[] = []
-  if (block instanceof Element) {
-    children.push(block)
-  } else if (isVaporComponent(block)) {
-    // A normal component child can move when parent-driven props update its
-    // root layout without re-running the surrounding v-for fragment.
-    // When the component root is a slot, the TransitionGroup children are the
-    // slotted blocks, so track the slot fragment instead of the component.
-    const isRootSlot = block.block && isSlotFragment(block.block)
-    if (onUpdateOwner && !isRootSlot) onUpdateOwner(block)
-    const blocks = getTransitionBlocks(
-      block.block,
-      onFragment,
-      // Only a root slot exposes nested blocks as TransitionGroup children.
-      // Other component internals should not trigger group move bookkeeping.
-      isRootSlot ? onUpdateOwner : undefined,
-    )
-    inheritKey(blocks, block.$key)
-    children.push(...blocks)
-  } else if (isArray(block)) {
-    for (let i = 0; i < block.length; i++) {
-      const b = block[i]
-      const blocks = getTransitionBlocks(b, onFragment, onUpdateOwner)
-      if (isForBlock(b)) blocks.forEach(block => (block.$key = b.key))
-      children.push(...blocks)
-    }
-  } else if (isFragment(block)) {
-    if (onFragment) onFragment(block)
-    if (onUpdateOwner) onUpdateOwner(block)
-    if (isInteropEnabled && block.vnode) {
-      // vdom component
-      children.push(block)
-    } else {
-      const blocks = getTransitionBlocks(block.nodes, onFragment, onUpdateOwner)
-      inheritKey(blocks, block.$key)
-      children.push(...blocks)
-    }
-  }
-
-  return children
-}
-
-function isValidTransitionBlock(
-  block: Block,
-): block is ResolvedTransitionBlock {
-  return !!(block instanceof Element || (isFragment(block) && block.vnode))
-}
-
-function getTransitionElement(
-  block: ResolvedTransitionBlock,
-): Element | undefined {
-  if (block instanceof Element) return block
-
-  // vdom interop
-  if (isInteropEnabled && isFragment(block) && block.vnode) {
-    return getTransitionElementFromVNode(block.vnode)
-  }
-}
-
 function recordPosition(c: ResolvedTransitionBlock) {
   const el = getTransitionElement(c)
   if (el) newPositionMap.set(c, el.getBoundingClientRect())