Jelajahi Sumber

feat(teleport): use moveBefore to preserve node state for vdom teleport

daiwei 3 bulan lalu
induk
melakukan
11bfdcfd32

+ 2 - 0
packages/runtime-core/src/components/Teleport.ts

@@ -390,6 +390,8 @@ function moveTeleport(
           parentAnchor,
           MoveType.REORDER,
           parentComponent,
+          null,
+          true, // preserveState - nodes are already mounted
         )
       }
     }

+ 15 - 4
packages/runtime-core/src/renderer.ts

@@ -137,7 +137,12 @@ export interface RendererOptions<
     namespace?: ElementNamespace,
     parentComponent?: ComponentInternalInstance | null,
   ): void
-  insert(el: HostNode, parent: HostElement, anchor?: HostNode | null): void
+  insert(
+    el: HostNode,
+    parent: HostElement,
+    anchor?: HostNode | null,
+    preserveState?: boolean,
+  ): void
   remove(el: HostNode): void
   createElement(
     type: string,
@@ -250,6 +255,7 @@ type MoveFn = (
   type: MoveType,
   parentComponent: ComponentInternalInstance | null,
   parentSuspense?: SuspenseBoundary | null,
+  preserveState?: boolean,
 ) => void
 
 type NextFn = (vnode: VNode) => RendererNode | null
@@ -2200,6 +2206,7 @@ function baseCreateRenderer(
     moveType,
     parentComponent,
     parentSuspense = null,
+    preserveState,
   ) => {
     const { el, type, transition, children, shapeFlag } = vnode
     if (shapeFlag & ShapeFlags.COMPONENT) {
@@ -2212,6 +2219,8 @@ function baseCreateRenderer(
           anchor,
           moveType,
           parentComponent,
+          parentSuspense,
+          preserveState,
         )
       }
       return
@@ -2234,7 +2243,7 @@ function baseCreateRenderer(
     }
 
     if (type === Fragment) {
-      hostInsert(el!, container, anchor)
+      hostInsert(el!, container, anchor, preserveState)
       for (let i = 0; i < (children as VNode[]).length; i++) {
         move(
           (children as VNode[])[i],
@@ -2242,9 +2251,11 @@ function baseCreateRenderer(
           anchor,
           moveType,
           parentComponent,
+          parentSuspense,
+          preserveState,
         )
       }
-      hostInsert(vnode.anchor!, container, anchor)
+      hostInsert(vnode.anchor!, container, anchor, preserveState)
       return
     }
 
@@ -2295,7 +2306,7 @@ function baseCreateRenderer(
         }
       }
     } else {
-      hostInsert(el!, container, anchor)
+      hostInsert(el!, container, anchor, preserveState)
     }
   }
 

+ 1 - 1
packages/runtime-dom/src/index.ts

@@ -382,4 +382,4 @@ export {
 /**
  * @internal
  */
-export { unsafeToTrustedHTML } from './nodeOps'
+export { unsafeToTrustedHTML, moveNode } from './nodeOps'

+ 24 - 2
packages/runtime-dom/src/nodeOps.ts

@@ -41,9 +41,31 @@ const doc = (typeof document !== 'undefined' ? document : null) as Document
 
 const templateContainer = doc && /*@__PURE__*/ doc.createElement('template')
 
+/**
+ * Move a node to a new position
+ * Prefers moveBefore (preserves node state), falls back to insertBefore
+ */
+/*@__NO_SIDE_EFFECTS__*/
+export const moveNode: (
+  parent: ParentNode & {
+    moveBefore?: ParentNode['insertBefore']
+  },
+  child: Node,
+  anchor: Node | null,
+) => void =
+  /*@__PURE__*/ (() =>
+    (__TEST__ ? typeof HTMLElement !== 'undefined' : true) &&
+    'moveBefore' in HTMLElement.prototype
+      ? (parent, child, anchor) => parent.moveBefore!(child, anchor)
+      : (parent, child, anchor) => parent.insertBefore(child, anchor))()
+
 export const nodeOps: Omit<RendererOptions<Node, Element>, 'patchProp'> = {
-  insert: (child, parent, anchor) => {
-    parent.insertBefore(child, anchor || null)
+  insert: (child, parent, anchor, preserveState) => {
+    if (preserveState) {
+      moveNode(parent, child, anchor || null)
+    } else {
+      parent.insertBefore(child, anchor || null)
+    }
   },
 
   remove: child => {

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

@@ -5,7 +5,7 @@ import {
   mountComponent,
   unmountComponent,
 } from './component'
-import { _child, moveNode } from './dom/node'
+import { _child } from './dom/node'
 import { isComment, isHydrating } from './dom/hydration'
 import {
   MoveType,
@@ -13,6 +13,7 @@ import {
   type TransitionProps,
   type TransitionState,
   getInheritedScopeIds,
+  moveNode,
   performTransitionEnter,
   performTransitionLeave,
 } from '@vue/runtime-dom'

+ 0 - 14
packages/runtime-vapor/src/dom/node.ts

@@ -26,20 +26,6 @@ export function parentNode(node: Node): ParentNode | null {
   return node.parentNode
 }
 
-/**
- * Move a node to a new position
- * Prefers moveBefore (preserves node state), falls back to insertBefore
- */
-/*@__NO_SIDE_EFFECTS__*/
-export const moveNode: (
-  parent: ParentNode & { moveBefore?: ParentNode['insertBefore'] },
-  node: Node,
-  anchor: Node | null,
-) => void = /*@__PURE__*/ (() =>
-  'moveBefore' in HTMLElement.prototype
-    ? (parent, node, anchor) => parent.moveBefore!(node, anchor)
-    : (parent, node, anchor) => parent.insertBefore(node, anchor))()
-
 /*@__NO_SIDE_EFFECTS__*/
 const _txt: typeof _child = _child
 

+ 23 - 0
packages/vue/__tests__/e2e/teleport.html

@@ -0,0 +1,23 @@
+<!doctype html>
+<html lang="en">
+
+<head>
+    <meta charset="UTF-8" />
+    <link rel="icon" href="data:;base64,iVBORw0KGgo=" />
+    <title>Teleport moveBefore Test</title>
+    <script src="../../dist/vue.global.js"></script>
+    <style>
+        .container {
+            padding: 10px;
+            border: 1px solid #ccc;
+            margin: 10px 0;
+        }
+    </style>
+</head>
+
+<body>
+    <div id="app"></div>
+    <div id="target" class="container"></div>
+</body>
+
+</html>

+ 134 - 0
packages/vue/__tests__/e2e/teleport.spec.ts

@@ -0,0 +1,134 @@
+import { once } from 'node:events'
+import { createServer } from 'node:http'
+import path from 'node:path'
+import { beforeAll } from 'vitest'
+import serveHandler from 'serve-handler'
+
+import { E2E_TIMEOUT, setupPuppeteer } from './e2eUtils'
+
+const serverRoot = path.resolve(import.meta.dirname, '../../')
+const testPort = 9091
+const basePath = path.relative(
+  serverRoot,
+  path.resolve(import.meta.dirname, './teleport.html'),
+)
+const baseUrl = `http://localhost:${testPort}/${basePath}`
+
+const { page, click, html } = setupPuppeteer()
+
+let server: ReturnType<typeof createServer>
+beforeAll(async () => {
+  server = createServer((req, res) => {
+    return serveHandler(req, res, {
+      public: serverRoot,
+      cleanUrls: false,
+    })
+  })
+
+  server.listen(testPort)
+  await once(server, 'listening')
+})
+
+afterAll(async () => {
+  server.close()
+  await once(server, 'close')
+})
+
+describe('e2e: Teleport', () => {
+  beforeEach(async () => {
+    await page().goto(baseUrl)
+    await page().waitForSelector('#app')
+  })
+
+  test(
+    'should preserve video playback state when toggling disabled',
+    async () => {
+      await page().evaluate(() => {
+        const { createApp, ref, h, Teleport } = (window as any).Vue
+
+        createApp({
+          setup() {
+            const disabled = ref(true)
+            return () =>
+              h('div', [
+                h(
+                  'button',
+                  {
+                    id: 'toggle',
+                    onClick: () => {
+                      disabled.value = !disabled.value
+                    },
+                  },
+                  'Toggle',
+                ),
+                h(
+                  Teleport,
+                  { to: '#target', disabled: disabled.value },
+                  h('video', {
+                    id: 'video',
+                    src: 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm',
+                    controls: true,
+                    muted: true,
+                    loop: true,
+                    autoplay: true,
+                  }),
+                ),
+              ])
+          },
+        }).mount('#app')
+      })
+
+      // Wait for video element
+      await page().waitForSelector('#video')
+
+      // Start playing the video and get initial currentTime
+      await page().evaluate(() => {
+        const video = document.querySelector('#video') as HTMLVideoElement
+        video.currentTime = 5
+        video.play()
+      })
+
+      // Wait a bit for playback
+      await page().evaluate(
+        () => new Promise(resolve => setTimeout(resolve, 100)),
+      )
+
+      // Get currentTime before toggle
+      const timeBefore = await page().evaluate(() => {
+        const video = document.querySelector('#video') as HTMLVideoElement
+        return video.currentTime
+      })
+
+      // Toggle disabled (move from main container to target)
+      await click('#toggle')
+
+      // Wait for DOM update
+      await page().evaluate(
+        () => new Promise(resolve => setTimeout(resolve, 50)),
+      )
+
+      // Get currentTime after toggle
+      const timeAfter = await page().evaluate(() => {
+        const video = document.querySelector('#video') as HTMLVideoElement
+        return video.currentTime
+      })
+
+      // Video should be in target now
+      expect(await html('#target')).toContain('<video')
+
+      // If moveBefore is supported, the time difference should be small
+      // (playback continues). If not supported, video restarts from 0.
+      // We check that the video didn't restart (time didn't reset to near 0)
+      const supportsMoveBefore = await page().evaluate(() => {
+        return 'moveBefore' in HTMLElement.prototype
+      })
+
+      if (supportsMoveBefore) {
+        // With moveBefore, playback state should be preserved
+        // timeAfter should be >= timeBefore (or very close, accounting for playback)
+        expect(timeAfter).toBeGreaterThanOrEqual(timeBefore)
+      }
+    },
+    E2E_TIMEOUT,
+  )
+})